From dd7d1e51bdb6835907af46aa5472235882c1bd3e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 26 Aug 2026 21:36:32 +0800 Subject: [PATCH 01/67] feat(workspace): attach the bound workspace's integration engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrations are served by the local datamate engine — the same process the VS Code extension spawns as `datamate start-stdio`. Altimate Code could reuse an entry an IDE had already written, but could not acquire an engine on its own: with no entry present it fell through to the hosted SSE endpoint, which runs in multi-user mode and serves a DIFFERENT tool set (no connection validation, no extension-bridge tools, server-side cwd). A terminal session in a bound project therefore had either the IDE's tools or the wrong ones. `workspace/engine-sync.ts` closes that gap with `ensure(sessionID)`, idempotent per session and gated on the workspace pilot flag. Its rules, in order: **Reuse.** A connected `datamate` MCP entry wins — that is an IDE-written or previously persisted entry, and attaching to it is free. If it is down, what it is decides what happens next. A URL entry is an IDE's in-process engine or the hosted endpoint; neither can be revived from here, so with a binding and a usable engine on PATH we spawn locally and report what was replaced. The IDE's own config is never touched. A command entry that failed is retried once, then reported — spawning a second engine beside a failing one is the duplicate-process problem the single-gateway design exists to avoid. **Opportunistic use, never an install.** A `datamate` on PATH whose `--version` clears the floor is spawned as `datamate start-stdio --datamate `, pinned to the bound workspace and persisted to the project config so later sessions start it at boot. With no engine present the user is told which workspace tools are unavailable and how to install one; the CLI ships as a self-contained binary with no Node runtime, so it must not pull one in. **Never fall back to hosted on failure.** The local and hosted tool sets diverge in both directions, so a silent fallback would change the workspace's declared contract. A failed engine is reported, not routed around. **Report what was declared but not delivered.** The engine intersects the workspace allowlist with what it managed to build and says nothing about the difference; this diffs declared keys against the tools that actually arrived and surfaces the gap. **First-turn readiness.** A turn resolves its tool list before the per-turn work that starts the attach, so a session that spawned its own engine listed the engine's tools one turn late — the model saw `datamate_manager` alone on the first turn and the integration tools only from the second. The attach now starts ahead of tool resolution and `whenAttached` gives it a bounded window, so those tools make the first tool list. A cold attach measures ~6.5s (≈1s to probe `--version`, ≈1s for the declared allowlist, ≈4.5s for the engine to boot, handshake and build its tools), against a 15s cap set well clear of that and far below MCP's own 30s connect timeout. Past the cap the turn proceeds and `tools/list_changed` delivers the tools when they land. Unbound and disabled sessions settle without I/O and wait for nothing. `datamate_manager list-integrations` now hides extension-type integrations, which are RPC into a live VS Code host and have no meaning on the CLI surface, and reports how many it hid rather than pretending they do not exist. Inert without a local binding. 21 unit tests cover the decision logic through the `syncInternals` seams. --- packages/opencode/src/altimate/api/client.ts | 5 + .../opencode/src/altimate/tools/datamate.ts | 21 +- .../src/altimate/workspace/engine-sync.ts | 466 ++++++++++++++++++ packages/opencode/src/session/prompt.ts | 24 + .../altimate/workspace/engine-sync.test.ts | 301 +++++++++++ 5 files changed, 814 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/engine-sync.ts create mode 100644 packages/opencode/test/altimate/workspace/engine-sync.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 85531e36b3..088981bef1 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -37,6 +37,11 @@ const DatamateSummary = z.object({ const IntegrationSummary = z.object({ id: z.coerce.string(), name: z.string().optional(), + // altimate_change start — catalog `type` (tool | mcp | code | api | extension). + // Extension-type integrations are RPC into a live VS Code host and have no + // meaning on the CLI surface; callers filter on this. + type: z.string().optional(), + // altimate_change end description: z.string().nullable().optional(), tools: z .array( diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..8f0897dc78 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -138,11 +138,18 @@ async function handleList() { async function handleListIntegrations() { try { - const integrations = await AltimateApi.listIntegrations() + const catalog = await AltimateApi.listIntegrations() + // altimate_change start — extension-type integrations need a live VS Code + // bridge and cannot work from the CLI. Hide them from this surface (the + // workspace UI still offers them), but say how many were hidden rather than + // pretending they don't exist. + const integrations = catalog.filter((i) => i.type !== "extension") + const hidden = catalog.length - integrations.length + // altimate_change end if (integrations.length === 0) { return { title: "Integrations: none found", - metadata: { count: 0 }, + metadata: { count: 0, hidden }, output: "No integrations available.", } } @@ -151,9 +158,17 @@ async function handleListIntegrations() { const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" lines.push(`${i.id} | ${i.name} | ${tools}`) } + // altimate_change start + if (hidden > 0) { + lines.push( + "", + `(${hidden} extension-type integration${hidden === 1 ? "" : "s"} omitted — they require a live VS Code bridge and are not available from the CLI.)`, + ) + } + // altimate_change end return { title: `Integrations: ${integrations.length} available`, - metadata: { count: integrations.length }, + metadata: { count: integrations.length, hidden }, output: lines.join("\n"), } } catch (e) { diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts new file mode 100644 index 0000000000..9b7fda68c9 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -0,0 +1,466 @@ +// altimate_change - new file +// +// Attach the bound workspace's integration engine to an altimate-code session. +// +// Integrations are served by the local datamate engine — the same process the +// VS Code extension spawns as `datamate start-stdio`. altimate-code can reuse an +// entry an IDE already wrote, but until now it could not acquire an engine on +// its own: with no entry present it fell to the hosted SSE endpoint, which runs +// in multi-user mode and serves a DIFFERENT tool set (no connection validation, +// no extension-bridge tools, server-side cwd). This module closes that gap. +// +// Rules, in order: +// 1. Reuse. A connected MCP server already registered under DATAMATE_KEY wins — +// that is an IDE-written or previously persisted entry, and attaching to it +// is free. If that entry is DOWN, what it is decides what happens next: +// - a URL entry is an IDE's in-process engine (normally localhost) or the +// hosted endpoint. Neither can be revived from here — only the IDE can +// bring its port back — so with a binding and a usable engine on PATH we +// spawn locally and say what was replaced. The IDE's own config file is +// never touched; when the IDE returns, its sync overwrites ours. +// - a command entry that failed is retried once, then reported. Spawning a +// second engine beside a failing one is the duplicate-process problem the +// single-gateway design exists to avoid. +// 2. Opportunistic use. If a `datamate` binary is on PATH and its `--version` +// clears the floor, spawn it for this workspace. A lookup, never an install. +// 3. Offer, never silently install. No engine → tell the user exactly which +// workspace tools are unavailable and how to install. The CLI ships as a +// self-contained binary with no Node runtime, so it must not pull one in. +// 4. NEVER fall back to hosted on failure. The local and hosted tool sets +// diverge in both directions, so a silent fallback would change the +// workspace's declared contract. A failed engine is reported, not routed +// around. +// 5. Report what was declared but not delivered. The engine intersects the +// workspace allowlist with what it managed to build and says nothing about +// the difference; this module diffs declared keys against the tools that +// actually arrived and surfaces the gap. +// +// Attaching runs beside the turn, not inside it: `ensure` is started before the +// turn's tools are resolved, and `whenAttached` gives a fresh spawn a bounded +// window to land so the engine's tools make the first tool list rather than +// arriving a turn late. Past the cap the turn proceeds and `tools/list_changed` +// delivers them. +// +// Gated on the workspace pilot flag; inert without a local binding. + +import { execFile } from "node:child_process" +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +import { which as whichBinary } from "@opencode-ai/core/util/which" +import { Instance } from "@/project/instance" +import { Log } from "@/altimate/util/log" +import { MCP } from "@/mcp" +import { addMcpToConfig, resolveConfigPath } from "@/mcp/config" +import { Config } from "@/config/config" +import { AltimateApi } from "@/altimate/api/client" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding, type CachedBinding } from "./state" + +const log = Log.create({ service: "workspace-engine" }) + +/** Oldest engine this client is known to work against. */ +export const MIN_ENGINE_VERSION = "0.6.3" +export const INSTALL_HINT = "npm i -g @altimateai/datamate" +export const ENGINE_BINARY = "datamate" + +/** Engine tools arrive under the MCP server key as `_`. */ +const TOOL_PREFIX = `${DATAMATE_KEY}_` + +export type Outcome = + | { kind: "disabled" } + | { kind: "unbound" } + | { kind: "reused"; available: number } + | { kind: "attached"; available: number; declared: number; missing: string[]; replaced?: string } + | { kind: "engine-missing"; declared: number } + | { kind: "engine-too-old"; found: string } + | { kind: "connect-failed"; error: string } + +export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } + +export type ExistingEntry = { type?: string; url?: string; command?: string[] } + +type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } + +type McpStatus = Record + +/** Declared allowlist for a workspace, split by whether the CLI can serve it. + * Extension-type integrations are RPC into a live VS Code host and have no + * meaning on the CLI surface, so they are excluded from the reported gap. */ +export type Declared = { keys: string[]; extensionKeys: string[] } + +/** Test seams. Production leaves every field unset. */ +export const syncInternals: { + resolveBinding?: () => Promise + which?: (cmd: string) => string | null + versionOf?: (bin: string) => Promise + mcp?: { + status: () => Promise + add: (name: string, cfg: LocalMcpConfig) => Promise + connect: (name: string) => Promise + tools: () => Promise> + } + persist?: (name: string, cfg: LocalMcpConfig) => Promise + /** The configured (merged) MCP entry under `name`, or null if none. */ + existingEntry?: (name: string) => Promise + declared?: (datamateId: string) => Promise + notify?: (toast: Toast) => Promise +} = {} + +export function isEnabled(): boolean { + return CoreFlag.ALTIMATE_WORKSPACE +} + +/** Numeric semver compare on the `major.minor.patch` core; pre-release tags + * are ignored. Returns <0, 0, >0. Non-numeric input compares as older. */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => + v + .trim() + .replace(/^v/, "") + .split("-")[0] + .split(".") + .map((n) => Number.parseInt(n, 10)) + const pa = parse(a) + const pb = parse(b) + for (let i = 0; i < 3; i++) { + const x = Number.isFinite(pa[i]) ? pa[i] : -1 + const y = Number.isFinite(pb[i]) ? pb[i] : -1 + if (x !== y) return x - y + } + return 0 +} + +/** Strip the server prefix from the engine tools present in the catalog. */ +export function engineToolKeys(tools: Record): Set { + const out = new Set() + for (const key of Object.keys(tools)) { + if (key.startsWith(TOOL_PREFIX)) out.add(key.slice(TOOL_PREFIX.length)) + } + return out +} + +// --------------------------------------------------------------------------- +// Production implementations behind the seams +// --------------------------------------------------------------------------- + +function currentDirectory(): string | null { + try { + return Instance.directory + } catch { + return null + } +} + +function projectRoot(): string { + const wt = Instance.worktree + return wt === "/" ? Instance.directory : wt +} + +async function resolveBinding(): Promise { + if (syncInternals.resolveBinding) return syncInternals.resolveBinding() + const directory = currentDirectory() + if (!directory) return null + try { + return await readLocalBinding(directory) + } catch (err) { + log.warn("could not resolve binding for engine attach", { err: String(err) }) + return null + } +} + +function which(cmd: string): string | null { + return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) +} + +/** `datamate --version` — the engine inlines its real package version here, + * unlike its MCP `serverInfo`, which is a hard-coded placeholder. A version + * string proves output, not identity; it is a compatibility floor only. */ +function versionOf(bin: string): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin) + return new Promise((resolve) => { + execFile(bin, ["--version"], { timeout: 5000 }, (err, stdout) => { + if (err) return resolve(null) + const line = String(stdout).trim().split(/\r?\n/)[0] ?? "" + resolve(line || null) + }) + }) +} + +function mcp() { + return ( + syncInternals.mcp ?? { + status: () => MCP.status() as Promise, + add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), + connect: (name: string) => MCP.connect(name), + tools: () => MCP.tools() as Promise>, + } + ) +} + +async function persist(name: string, cfg: LocalMcpConfig): Promise { + if (syncInternals.persist) return syncInternals.persist(name, cfg) + const configPath = await resolveConfigPath(projectRoot()) + await addMcpToConfig(name, cfg, configPath) +} + +async function existingEntry(name: string): Promise { + if (syncInternals.existingEntry) return syncInternals.existingEntry(name) + try { + const cfg = (await Config.get()) as { mcp?: Record } + return cfg.mcp?.[name] ?? null + } catch (err) { + log.warn("could not read merged MCP config", { name, err: String(err) }) + return null + } +} + +/** URL-based entries (`type: "remote"`, or any `url`) point at a process this + * client does not own: an IDE's in-process engine, or the hosted endpoint. */ +function isUrlEntry(entry: ExistingEntry | null): entry is ExistingEntry & { url: string } { + return !!entry && (entry.type === "remote" || typeof entry.url === "string") +} + +async function declared(datamateId: string): Promise { + if (syncInternals.declared) return syncInternals.declared(datamateId) + try { + if (!(await AltimateApi.isConfigured())) return null + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(datamateId), + AltimateApi.listIntegrations(), + ]) + const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id)) + const keys: string[] = [] + const extensionKeys: string[] = [] + for (const integration of workspace.integrations ?? []) { + const target = extensionIds.has(integration.id) ? extensionKeys : keys + for (const tool of integration.tools ?? []) target.push(tool.key) + } + return { keys, extensionKeys } + } catch (err) { + log.warn("could not read declared workspace integrations", { datamateId, err: String(err) }) + return null + } +} + +async function notify(toast: Toast): Promise { + if (syncInternals.notify) return syncInternals.notify(toast) + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })), + ) + } catch (err) { + log.warn("could not show workspace engine toast", { err: String(err) }) + } +} + +// --------------------------------------------------------------------------- +// The attach flow +// --------------------------------------------------------------------------- + +function describeMissing(missing: string[]): string { + if (missing.length === 0) return "" + const shown = missing.slice(0, 5).join(", ") + const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" + return ` Declared but not available: ${shown}${more}.` +} + +async function run(): Promise { + if (!isEnabled()) return { kind: "disabled" } + + const binding = await resolveBinding() + if (!binding) return { kind: "unbound" } + const workspaceId = String(binding.datamateId) + const client = mcp() + + // Rule 1 — reuse whatever already serves this session. + let replaced: string | undefined + const before = await client.status() + const existing = before[DATAMATE_KEY] + if (existing) { + let connected = existing.status === "connected" + if (!connected) { + const entry = await existingEntry(DATAMATE_KEY) + if (isUrlEntry(entry)) { + // Dead URL: nothing here can bring that process back. Fall through to a + // local spawn (if one is possible) and report the replacement below. + replaced = entry.url + log.info("existing engine entry is a URL that is not reachable; will spawn locally", { + workspaceId, + url: entry.url, + error: existing.error, + }) + } else { + // A command entry that failed: one retry, then report — never a second spawn. + await client.connect(DATAMATE_KEY).catch(() => undefined) + const retried = (await client.status())[DATAMATE_KEY] + connected = retried?.status === "connected" + if (!connected) { + const error = retried?.error ?? retried?.status ?? "not connected" + await notify({ + title: "Workspace engine is not running", + message: `The "${DATAMATE_KEY}" MCP entry for workspace "${binding.datamateName}" could not connect: ${error}. Integration tools are unavailable until it does.`, + variant: "error", + }) + return { kind: "connect-failed", error } + } + } + } + if (connected) { + const available = engineToolKeys(await client.tools()).size + log.info("reusing existing engine entry", { workspaceId, available }) + return { kind: "reused", available } + } + } + + const declaredKeys = await declared(workspaceId) + const declaredCount = declaredKeys?.keys.length ?? 0 + + // Rule 2 / 3 — opportunistic use, or an offer. Never an install. + const bin = which(ENGINE_BINARY) + if (!bin) { + await notify({ + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + return { kind: "engine-missing", declared: declaredCount } + } + + const found = await versionOf(bin) + if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { + const label = found ?? "unknown" + await notify({ + title: "Workspace engine is too old", + message: `Found ${ENGINE_BINARY} ${label}; this client needs ${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, + variant: "warning", + }) + return { kind: "engine-too-old", found: label } + } + + // Spawn under the same server key the IDE uses, bound to THIS workspace. + // `--datamate` is pinned engine-side so the settings watcher cannot swap it. + const cfg: LocalMcpConfig = { + type: "local", + command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], + enabled: true, + } + await persist(DATAMATE_KEY, cfg) + await client.add(DATAMATE_KEY, cfg) + + // Rule 4 — a failed local engine is reported, never routed around. + const after = (await client.status())[DATAMATE_KEY] + if (after?.status !== "connected") { + const error = after?.error ?? after?.status ?? "not connected" + await notify({ + title: "Workspace engine failed to start", + message: `Could not start ${ENGINE_BINARY} for workspace "${binding.datamateName}": ${error}. Integration tools are unavailable; not falling back to the hosted endpoint because it serves a different tool set.`, + variant: "error", + }) + return { kind: "connect-failed", error } + } + + // Rule 5 — report declared-but-missing. + const present = engineToolKeys(await client.tools()) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + const replacedNote = replaced ? ` Replaced the unreachable engine URL ${replaced} for this session.` : "" + await notify({ + title: `Workspace "${binding.datamateName}" connected`, + message: + (declaredKeys + ? `${available} of ${declaredCount} declared integration tools available.` + : `${available} integration tools available.`) + + describeMissing(missing) + + replacedNote, + variant: missing.length > 0 ? "warning" : "success", + }) + log.info("attached workspace engine", { workspaceId, available, declared: declaredCount, missing, replaced }) + return { kind: "attached", available, declared: declaredCount, missing, ...(replaced ? { replaced } : {}) } +} + +// --------------------------------------------------------------------------- +// Public entry — idempotent per session, never throws. `ensure` never blocks a +// turn; `whenAttached` is the one bounded wait, and only turn 1 pays it. +// --------------------------------------------------------------------------- + +/** How long a turn may wait for a fresh attach before proceeding without it. + * + * A cold attach measured ~6.5s on a warm machine — ~1s to probe `--version`, + * ~1s for the workspace's declared allowlist, and ~4.5s for the engine to boot, + * handshake, and build its tools — and crossed 8s under the load of a real + * turn. The cap is set well clear of that so the common case lands inside it, + * and still far below MCP's own 30s connect timeout so an engine that never + * answers costs the first turn a pause rather than the turn itself. */ +export const ATTACH_WAIT_MS = 15_000 + +type SessionAttach = { task: Promise; waitTimedOut?: boolean } + +const sessions = new Map() + +export async function ensure(sessionID: string): Promise { + const existing = sessions.get(sessionID) + if (existing) return existing.task + const task = run() + .catch((err): Outcome => { + log.warn("workspace engine attach failed", { err: String(err) }) + return { kind: "connect-failed", error: String(err) } + }) + .then((outcome) => { + // One line per session, whatever happened — silence is the defect this + // module exists to remove, so it must not be silent about itself. + log.info("workspace engine outcome", { sessionID, ...outcome }) + return outcome + }) + sessions.set(sessionID, { task }) + return task +} + +/** Wait for a session's in-flight attach, capped. + * + * A turn resolves its tool list up front, before the per-turn block that starts + * the attach runs. A session that spawns its own engine therefore listed the + * engine's tools one turn late — the model saw `datamate_manager` alone on turn + * 1 and the integration tools only from turn 2. The caller starts `ensure` + * ahead of tool resolution and waits here to close that gap. + * + * Only a turn that actually spawns pays for it: `disabled` and `unbound` settle + * with no I/O beyond a local cache read, and a reused entry settles as fast as + * the status call it already makes. On timeout the turn proceeds without the + * engine's tools and `tools/list_changed` delivers them when the attach lands. */ +export async function whenAttached(sessionID: string, timeoutMs: number = ATTACH_WAIT_MS): Promise { + const state = sessions.get(sessionID) + if (!state) return + // A wait that already blew its budget must not be paid again: the caller's + // block runs on every user turn, and a hung engine keeps this promise pending + // for MCP's full connect timeout, so every later turn would pay the cap too. + if (state.waitTimedOut) return + let timer: ReturnType | undefined + let timedOut = false + try { + await Promise.race([ + state.task, + new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true + resolve() + }, timeoutMs) + timer.unref?.() + }), + ]) + } finally { + if (timer) clearTimeout(timer) + if (timedOut) { + state.waitTimedOut = true + log.info("workspace engine attach did not land in time for this turn", { sessionID, timeoutMs }) + } + } +} + +/** Test seam — drop memoised outcomes. */ +export function resetForTests(): void { + sessions.clear() +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..96e092e34c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,6 +25,7 @@ import { MemoryPrompt } from "../memory/prompt" import { UNIFIED_INJECTION_BUDGET } from "../memory/types" // altimate_change - workspace memory read path import * as WorkspaceMemory from "../altimate/workspace/memory-sync" +import * as WorkspaceEngine from "../altimate/workspace/engine-sync" import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -1007,6 +1008,27 @@ export namespace SessionPrompt { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + // altimate_change start — workspace engine readiness. + // + // `resolveTools` below snapshots the MCP tool catalog, and it runs ahead of + // the per-turn block further down where this attach used to be started. A + // session that spawned its own engine therefore listed the engine's tools one + // turn late: the model saw `datamate_manager` alone on the first turn and the + // integration tools only from the second. Starting the attach here and giving + // it a bounded window puts them in the first tool list instead. + // + // Only a turn that actually spawns waits: an unbound or disabled session + // settles with no I/O beyond a local cache read, and an engine an IDE already + // runs is reused as fast as the status call it already makes. Past the cap the + // turn proceeds without those tools and `tools/list_changed` delivers them + // when the attach lands. `ensure` is idempotent per session id, so the later + // turns this block also runs on return the settled outcome immediately. + if (step === 1) { + void WorkspaceEngine.ensure(sessionID).catch(() => {}) + await WorkspaceEngine.whenAttached(sessionID) + } + // altimate_change end + // altimate_change start (AI-7519) — trace resolveTools per step. // Included in the parent `bootstrap` span on step===1; on later steps // this measures the per-turn tool-listing overhead (MCP.tools connect @@ -1054,6 +1076,8 @@ export namespace SessionPrompt { // before the refetch, so workspace memory blinked out of the prompt whenever a // fetch ran long. void WorkspaceMemory.hydrate(sessionID).catch(() => {}) + // The bound workspace's integration engine is attached above, ahead of + // `resolveTools`, because its tools have to be in that turn's tool list. // altimate_change end SessionSummary.summarize({ sessionID: sessionID, diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts new file mode 100644 index 0000000000..39be6ec942 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -0,0 +1,301 @@ +// altimate_change - new file +// +// Unit coverage for the workspace → local engine attach flow. Every side +// effect goes through `syncInternals`, so this exercises the decision logic +// without booting an instance, spawning a process, or touching MCP state. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + compareVersions, + engineToolKeys, + ensure, + resetForTests, + syncInternals, + whenAttached, + ATTACH_WAIT_MS, + INSTALL_HINT, + type LocalMcpConfig, +} from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + toasts: Array<{ title: string; message: string; variant: string }> + statusQueue: Array> + tools: Record +} + +function install(opts: { + binding?: CachedBinding | null + which?: string | null + version?: string | null + declared?: { keys: string[]; extensionKeys: string[] } | null + statuses?: Harness["statusQueue"] + tools?: Record + existing?: { type?: string; url?: string; command?: string[] } | null +}): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + toasts: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + } + syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async () => (opts.version === undefined ? "0.6.3" : opts.version) + syncInternals.declared = async () => + opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } : opts.declared + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => (opts.existing === undefined ? null : opts.existing) + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.mcp = { + status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + connect: async (name) => { + h.connects.push(name) + }, + tools: async () => h.tools, + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +describe("compareVersions", () => { + test("orders numerically, not lexically", () => { + expect(compareVersions("0.10.0", "0.6.3")).toBeGreaterThan(0) + expect(compareVersions("0.6.3", "0.6.3")).toBe(0) + expect(compareVersions("0.6.2", "0.6.3")).toBeLessThan(0) + }) + test("tolerates a v prefix and a pre-release tag", () => { + expect(compareVersions("v0.7.0-beta.1", "0.6.3")).toBeGreaterThan(0) + }) + test("garbage compares as older", () => { + expect(compareVersions("not-a-version", "0.6.3")).toBeLessThan(0) + }) +}) + +describe("engineToolKeys", () => { + test("keeps only datamate_-prefixed tools and strips the prefix", () => { + const keys = engineToolKeys({ datamate_dbt_build_model: 1, sql_execute: 1, other_x: 1 }) + expect([...keys]).toEqual(["dbt_build_model"]) + }) +}) + +describe("ensure", () => { + test("is inert when the pilot flag is off", async () => { + delete process.env.ALTIMATE_WORKSPACE + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "disabled" }) + expect(h.added).toHaveLength(0) + }) + + test("is inert with no local binding", async () => { + const h = install({ binding: null }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toHaveLength(0) + expect(h.toasts).toHaveLength(0) + }) + + test("reuses an already-connected engine entry without spawning", async () => { + const h = install({ + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2 }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("offers the install when no engine is on PATH — and does NOT fall back to hosted", async () => { + const h = install({ which: null }) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain('Workspace "analytics" declares 2 integration tools') + expect(h.toasts[0].message).toContain(INSTALL_HINT) + }) + + test("refuses an engine below the version floor", async () => { + const h = install({ version: "0.5.9" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.added).toHaveLength(0) + }) + + test("spawns the engine pinned to the bound workspace and reports the declared-vs-delivered gap", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, sql_execute: 1 }, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "attached", available: 1, declared: 2, missing: ["dbt_compile_model"] }) + + expect(h.persisted).toHaveLength(1) + expect(h.added).toHaveLength(1) + const cfg = h.added[0].cfg + expect(h.added[0].name).toBe("datamate") + expect(cfg.type).toBe("local") + expect(cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain("1 of 2 declared integration tools available") + expect(h.toasts[0].message).toContain("dbt_compile_model") + }) + + test("a clean attach reports success with no gap", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "attached", available: 2, declared: 2, missing: [] }) + expect(h.toasts[0].variant).toBe("success") + }) + + test("a failed spawn is reported, never routed to hosted", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "failed", error: "spawn ENOENT" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "spawn ENOENT" }) + // exactly one add, and it was the LOCAL spawn — no second, remote config + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.type).toBe("local") + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("error") + expect(h.toasts[0].message).toContain("not falling back to the hosted endpoint") + }) + + test("a down COMMAND entry is retried once, then reported — never double-spawned", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "exit 1" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) + expect(h.connects).toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("a dead URL entry (IDE engine not running) is replaced by a local spawn, and the replacement is reported", async () => { + const h = install({ + existing: { type: "remote", url: "http://localhost:7801/sse" }, + statuses: [ + { datamate: { status: "failed", error: "SSE error: Unable to connect" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ + kind: "attached", + available: 2, + declared: 2, + missing: [], + replaced: "http://localhost:7801/sse", + }) + expect(h.connects).toHaveLength(0) // no pointless retry of a dead port + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.toasts[0].message).toContain("Replaced the unreachable engine URL http://localhost:7801/sse") + }) + + test("is idempotent per session", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const first = await ensure("s1") + const second = await ensure("s1") + expect(second).toBe(first) + expect(h.added).toHaveLength(1) + }) +}) + +describe("whenAttached", () => { + test("the cap stays well under MCP's own connect timeout", () => { + // A turn must never inherit MCP's 30s connect budget; past this cap the + // tools arrive over `tools/list_changed` instead. + expect(ATTACH_WAIT_MS).toBeLessThan(30_000) + }) + + test("does not wait when no attach was started for the session", async () => { + install({}) + const started = performance.now() + await whenAttached("never-ensured", 1_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("returns once a fresh attach has landed, so its tools make this turn", async () => { + const h = install({ tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 } }) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(1_000) + // The engine is connected by the time the caller resolves its tool list. + expect(h.added).toHaveLength(1) + expect(engineToolKeys(h.tools).size).toBe(2) + }) + + test("an unbound session settles without waiting", async () => { + install({ binding: null }) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("a disabled session settles without waiting", async () => { + delete process.env.ALTIMATE_WORKSPACE + install({}) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("gives up after the cap, and later turns in the session do not pay it again", async () => { + install({}) + // An engine that never answers: the attach promise stays pending for MCP's + // full connect budget, which no turn may inherit. + syncInternals.versionOf = () => new Promise(() => {}) + void ensure("s1") + + const first = performance.now() + await whenAttached("s1", 25) + expect(performance.now() - first).toBeGreaterThanOrEqual(20) + + // Every user turn runs the same block; only the first one waits. + const second = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - second).toBeLessThan(50) + }) +}) From dfb183807da9b18c291d0e0cdeaaace032372d16 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 26 Aug 2026 22:51:42 +0800 Subject: [PATCH 02/67] fix(workspace): reuse only an engine attributable to the bound workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 1 reused any CONNECTED `datamate` entry without inspecting it. That is not enough to know whose engine it is. `--datamate ` is the whole of an engine's workspace identity, and the extension writes its entry WITHOUT one (`datamate start-stdio`), so that engine serves whichever teammate the IDE has active — and that changes at runtime, from a UI this client does not control. The consequence was a silent cross-workspace path: a session bound to workspace A could reuse an engine serving B, then report "workspace A: N tools" about it. Attach alone would merely hand over the wrong tools, but workspace precedence acts on that inventory — it would shadow local connections by B's types and route the model into B's credentials, under a no-hosted-fallback rule, with nothing naming the discrepancy. An entry is now reused only when it is live AND pinned to this workspace AND its binary clears the version floor. Anything else that is live — unpinned, pinned elsewhere, below the floor, or a URL — is replaced by a pinned local spawn and what it was is reported. That costs the other client nothing: a stdio entry is a per-client child process, so an IDE keeps its own engine and only our registration changes. A connected URL entry is replaced for the same reason rule 4 exists: the hosted endpoint serves a different tool set. A retry that brings a dropped entry back is gated identically, which it was not before. Two things fall out of the same mechanism: **Replacing a live entry closes it first.** `MCP.add` does not close the client it overwrites, so adding over a running stdio server starts a second engine and abandons the first with its pipes open — the duplicate-engine hazard this module already refuses for a failing entry. Left in, it wedged the session; observed as a hang, and reproduced against the previous commit as a clean reuse. **The floor is enforced on reuse, not only on spawn.** A stale persisted entry could otherwise keep an engine old enough that its `--datamate` pin is not locked — exactly the drift the attribution check exists to exclude. Below the floor, a newer engine on PATH is preferred; if PATH cannot do better, it is reported rather than reused. `MIN_ENGINE_VERSION` moves to 0.7.0, the first engine that locks the pin. SEQUENCING: this must not merge before `@altimateai/datamate` 0.7.0 is on npm, or every bound user gets `engine-too-old` for a version they cannot install. 15 further tests: the pin parser over both config shapes, both flag spellings and last-wins; each of the three connected-entry states; the recovered-entry gate; the disconnect-before-spawn contract; and the floor on the reuse path. --- .../src/altimate/workspace/engine-sync.ts | 173 ++++++++++++++++-- .../altimate/workspace/engine-sync.test.ts | 167 ++++++++++++++++- 2 files changed, 320 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 9b7fda68c9..4e65121673 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -10,9 +10,21 @@ // no extension-bridge tools, server-side cwd). This module closes that gap. // // Rules, in order: -// 1. Reuse. A connected MCP server already registered under DATAMATE_KEY wins — -// that is an IDE-written or previously persisted entry, and attaching to it -// is free. If that entry is DOWN, what it is decides what happens next: +// 1. Reuse, but only what is ATTRIBUTABLE. An entry already registered under +// DATAMATE_KEY is reused only when it is live AND its command pins the +// engine to this workspace (`--datamate `) AND that binary clears the +// version floor. Being connected proves none of that: an unpinned engine +// serves whichever teammate its owner has active, and that changes at +// runtime from a UI this client does not control — the extension writes +// exactly such an entry. Reusing one would report "workspace X: N tools" +// about a process serving Y. +// Anything live but not attributable — unpinned, pinned elsewhere, below the +// floor, or a URL — is replaced by a pinned local spawn, and what it was is +// reported. That costs the other client nothing: a stdio entry is a +// per-client child process, so the IDE keeps its own engine and only our +// registration changes. A connected URL entry is replaced for the same +// reason rule 4 exists — the hosted endpoint serves a different tool set. +// If the entry is DOWN, what it is decides what happens first: // - a URL entry is an IDE's in-process engine (normally localhost) or the // hosted endpoint. Neither can be revived from here — only the IDE can // bring its port back — so with a binding and a usable engine on PATH we @@ -20,7 +32,8 @@ // never touched; when the IDE returns, its sync overwrites ours. // - a command entry that failed is retried once, then reported. Spawning a // second engine beside a failing one is the duplicate-process problem the -// single-gateway design exists to avoid. +// single-gateway design exists to avoid. A retry that succeeds is then +// gated for attribution exactly like an entry that never dropped. // 2. Opportunistic use. If a `datamate` binary is on PATH and its `--version` // clears the floor, spawn it for this workspace. A lookup, never an install. // 3. Offer, never silently install. No engine → tell the user exactly which @@ -60,8 +73,16 @@ import { readLocalBinding, type CachedBinding } from "./state" const log = Log.create({ service: "workspace-engine" }) -/** Oldest engine this client is known to work against. */ -export const MIN_ENGINE_VERSION = "0.6.3" +/** Oldest engine this client is known to work against. + * + * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings + * change cannot swap the workspace out from under a running engine. Everything + * below it can drift, which is precisely what the attribution check in rule 1 + * exists to exclude — so the floor and that check are one mechanism, not two. + * + * SEQUENCING: this must not ship before `@altimateai/datamate` 0.7.0 is on npm, + * or every bound user gets `engine-too-old` for a version they cannot install. */ +export const MIN_ENGINE_VERSION = "0.7.0" export const INSTALL_HINT = "npm i -g @altimateai/datamate" export const ENGINE_BINARY = "datamate" @@ -79,7 +100,11 @@ export type Outcome = export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } -export type ExistingEntry = { type?: string; url?: string; command?: string[] } +/** A configured MCP entry, in either shape it can reach us: opencode's own + * `command: string[]` argv, or the `{ command, args }` split an IDE writes and + * `datamate-transport` normalises. Read defensively — this is merged config + * written by other clients. */ +export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[] } type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } @@ -99,6 +124,7 @@ export const syncInternals: { status: () => Promise add: (name: string, cfg: LocalMcpConfig) => Promise connect: (name: string) => Promise + disconnect: (name: string) => Promise tools: () => Promise> } persist?: (name: string, cfg: LocalMcpConfig) => Promise @@ -194,6 +220,7 @@ function mcp() { status: () => MCP.status() as Promise, add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), connect: (name: string) => MCP.connect(name), + disconnect: (name: string) => MCP.disconnect(name), tools: () => MCP.tools() as Promise>, } ) @@ -222,6 +249,43 @@ function isUrlEntry(entry: ExistingEntry | null): entry is ExistingEntry & { url return !!entry && (entry.type === "remote" || typeof entry.url === "string") } +const PIN_FLAG = "--datamate" + +/** The entry's full argv, flattening both config shapes. */ +function commandArgv(entry: ExistingEntry | null): string[] { + if (!entry) return [] + const head = typeof entry.command === "string" ? [entry.command] : (entry.command ?? []) + return [...head, ...(entry.args ?? [])] +} + +/** Which workspace does this entry pin its engine to, if any? + * + * `--datamate ` is the whole of an engine's workspace identity: the engine + * locks it, so a settings change cannot swap it out underneath. An entry + * WITHOUT it is not neutral — it serves whichever teammate its owner currently + * has active, and that changes at runtime from a UI this client does not + * control. The extension writes exactly such an entry (`datamate start-stdio`, + * no pin), so "connected" alone never proves an engine serves this workspace. + * + * Scanned from the end because a repeated flag resolves last-wins, and both the + * `--datamate 5` and `--datamate=5` spellings are valid on the engine's CLI. */ +export function pinnedWorkspace(entry: ExistingEntry | null): string | null { + const argv = commandArgv(entry) + for (let i = argv.length - 1; i >= 0; i--) { + const arg = argv[i] + if (arg === PIN_FLAG) return argv[i + 1] ?? null + if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null + } + return null +} + +/** Short, printable identity of an entry, for saying what was replaced. */ +function describeEntry(entry: ExistingEntry | null): string { + if (isUrlEntry(entry)) return entry.url + const argv = commandArgv(entry) + return argv.length > 0 ? argv.join(" ") : "an engine entry with no command" +} + async function declared(datamateId: string): Promise { if (syncInternals.declared) return syncInternals.declared(datamateId) try { @@ -274,25 +338,45 @@ async function run(): Promise { const workspaceId = String(binding.datamateId) const client = mcp() - // Rule 1 — reuse whatever already serves this session. + // Rule 1 — reuse what already serves this session, but only if it can be shown + // to serve THIS workspace, on an engine that still clears the floor. + // + // "Connected" is not that proof. An entry without `--datamate ` follows + // its owner's active teammate, and that changes at runtime from a UI this + // client does not control — the extension writes exactly such an entry. Reusing + // one would let us report "workspace X: N tools" about a process serving Y, + // and once precedence acts on that inventory it would route the model into + // another workspace's credentials, with no fallback and nothing naming the + // discrepancy. The floor is re-checked here for the same reason: a stale + // persisted entry can be running an engine old enough that its pin is not + // locked, which is the drift this attribution is meant to exclude. let replaced: string | undefined + let replacedNote = "" + /** Was the entry we are replacing still RUNNING? Then it must be closed before + * we spawn, or `MCP.add` leaves its child process orphaned beside the new one — + * the same duplicate-engine hazard this module refuses for a failing entry, and + * in practice it wedges the session. */ + let replacedLive = false const before = await client.status() const existing = before[DATAMATE_KEY] if (existing) { + const entry = await existingEntry(DATAMATE_KEY) let connected = existing.status === "connected" + if (!connected) { - const entry = await existingEntry(DATAMATE_KEY) if (isUrlEntry(entry)) { - // Dead URL: nothing here can bring that process back. Fall through to a - // local spawn (if one is possible) and report the replacement below. + // Dead URL: nothing here can bring that process back — only the IDE can + // restore its port. Fall through to a local spawn and report it below. replaced = entry.url + replacedNote = ` Replaced the unreachable engine URL ${entry.url} for this session.` log.info("existing engine entry is a URL that is not reachable; will spawn locally", { workspaceId, url: entry.url, error: existing.error, }) } else { - // A command entry that failed: one retry, then report — never a second spawn. + // A command entry that failed: one retry, then report — never a second + // spawn beside a failing one. await client.connect(DATAMATE_KEY).catch(() => undefined) const retried = (await client.status())[DATAMATE_KEY] connected = retried?.status === "connected" @@ -307,10 +391,60 @@ async function run(): Promise { } } } + + // Live — either it already was, or the single retry brought it back. A + // recovered entry is gated exactly like one that never dropped. if (connected) { - const available = engineToolKeys(await client.tools()).size - log.info("reusing existing engine entry", { workspaceId, available }) - return { kind: "reused", available } + const pin = pinnedWorkspace(entry) + if (pin !== workspaceId) { + // Not attributable to this workspace. Replacing it costs the other + // client nothing: a stdio entry is a per-client child process, so the + // IDE keeps its own engine and only OUR registration changes. A + // connected URL entry lands here too, which is the point — the hosted + // endpoint serves a different tool set, and rule 4 forbids adopting it. + replaced = describeEntry(entry) + replacedLive = true + replacedNote = pin + ? ` Replaced an engine entry pinned to workspace ${pin} for this session.` + : ` Replaced an engine entry that is not pinned to this workspace (${replaced}) for this session; it serves whichever workspace its owner has active.` + log.info("existing engine entry is not attributable to this workspace; will spawn locally", { + workspaceId, + pinnedTo: pin, + entry: replaced, + }) + } else { + const entryBin = commandArgv(entry)[0] + const found = entryBin ? await versionOf(entryBin) : null + if (found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) { + const available = engineToolKeys(await client.tools()).size + log.info("reusing existing engine entry", { workspaceId, available, version: found }) + return { kind: "reused", available } + } + // Pinned to us, but below the floor or unreadable. Prefer a newer engine + // on PATH over keeping one whose pin the engine does not lock; if PATH + // cannot do better, say so rather than reuse it silently. + const onPath = which(ENGINE_BINARY) + const pathVersion = onPath ? await versionOf(onPath) : null + if (!pathVersion || compareVersions(pathVersion, MIN_ENGINE_VERSION) < 0) { + const label = found ?? "unknown" + await notify({ + title: "Workspace engine is too old", + message: + `The engine serving workspace "${binding.datamateName}" reports ${label}; this client needs ` + + `${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, + variant: "warning", + }) + return { kind: "engine-too-old", found: label } + } + replaced = describeEntry(entry) + replacedLive = true + replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` + log.info("existing engine entry is below the version floor; will spawn locally", { + workspaceId, + found, + pathVersion, + }) + } } } @@ -348,6 +482,14 @@ async function run(): Promise { command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], enabled: true, } + if (replacedLive) { + // Close the live registration first: `MCP.add` does not close the client it + // overwrites, so adding over a running stdio server starts a second engine + // and abandons the first with its pipes still open. + await client.disconnect(DATAMATE_KEY).catch((err) => { + log.warn("could not close the engine entry being replaced", { err: String(err) }) + }) + } await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) @@ -367,7 +509,6 @@ async function run(): Promise { const present = engineToolKeys(await client.tools()) const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] const available = present.size - const replacedNote = replaced ? ` Replaced the unreachable engine URL ${replaced} for this session.` : "" await notify({ title: `Workspace "${binding.datamateName}" connected`, message: diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 39be6ec942..c05f5d5cf3 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -10,9 +10,11 @@ import { ensure, resetForTests, syncInternals, + pinnedWorkspace, whenAttached, ATTACH_WAIT_MS, INSTALL_HINT, + MIN_ENGINE_VERSION, type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -30,6 +32,7 @@ type Harness = { added: Array<{ name: string; cfg: LocalMcpConfig }> persisted: Array<{ name: string; cfg: LocalMcpConfig }> connects: string[] + disconnects: string[] toasts: Array<{ title: string; message: string; variant: string }> statusQueue: Array> tools: Record @@ -38,23 +41,27 @@ type Harness = { function install(opts: { binding?: CachedBinding | null which?: string | null - version?: string | null + version?: string | null | ((bin: string) => string | null) declared?: { keys: string[]; extensionKeys: string[] } | null statuses?: Harness["statusQueue"] tools?: Record - existing?: { type?: string; url?: string; command?: string[] } | null + existing?: { type?: string; url?: string; command?: string[] | string; args?: string[] } | null }): Harness { const h: Harness = { added: [], persisted: [], connects: [], + disconnects: [], toasts: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, } syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) - syncInternals.versionOf = async () => (opts.version === undefined ? "0.6.3" : opts.version) + syncInternals.versionOf = async (bin) => { + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } syncInternals.declared = async () => opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } : opts.declared syncInternals.persist = async (name, cfg) => { @@ -72,6 +79,9 @@ function install(opts: { connect: async (name) => { h.connects.push(name) }, + disconnect: async (name) => { + h.disconnects.push(name) + }, tools: async () => h.tools, } return h @@ -124,8 +134,9 @@ describe("ensure", () => { expect(h.toasts).toHaveLength(0) }) - test("reuses an already-connected engine entry without spawning", async () => { + test("reuses a connected entry that is pinned to this workspace, without spawning", async () => { const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, statuses: [{ datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, }) @@ -223,6 +234,7 @@ describe("ensure", () => { replaced: "http://localhost:7801/sse", }) expect(h.connects).toHaveLength(0) // no pointless retry of a dead port + expect(h.disconnects).toHaveLength(0) // a dead URL has nothing live to close expect(h.added).toHaveLength(1) expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) expect(h.toasts[0].message).toContain("Replaced the unreachable engine URL http://localhost:7801/sse") @@ -299,3 +311,150 @@ describe("whenAttached", () => { expect(performance.now() - second).toBeLessThan(50) }) }) + +describe("pinnedWorkspace", () => { + test("reads the pin from opencode's argv shape", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("reads the pin from the IDE's { command, args } shape", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("accepts the --datamate=5 spelling", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate=5"] })).toBe("5") + }) + test("a repeated flag resolves last-wins, as the engine's CLI does", () => { + expect( + pinnedWorkspace({ type: "local", command: ["datamate", "--datamate", "5", "--datamate", "9"] }), + ).toBe("9") + }) + test("an entry with no pin is not attributable — this is what the extension writes", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio"] })).toBeNull() + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio"] })).toBeNull() + }) + test("a URL entry pins nothing, and a missing entry is not attributable", () => { + expect(pinnedWorkspace({ type: "remote", url: "http://localhost:7801/sse" })).toBeNull() + expect(pinnedWorkspace(null)).toBeNull() + }) + test("a dangling --datamate with no value is not a pin", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate"] })).toBeNull() + }) +}) + +describe("ensure — attribution of a CONNECTED entry", () => { + const liveTwice: Harness["statusQueue"] = [ + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ] + const twoTools = { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 } + + test("an UNPINNED entry is replaced by a pinned spawn — this is the extension's entry", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ + kind: "attached", + available: 2, + declared: 2, + missing: [], + replaced: "datamate start-stdio", + }) + // The replacement is a pinned spawn, so the engine we end up on is ours. + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + // ...and the live one it displaced was CLOSED first. `MCP.add` does not close + // the client it overwrites, so skipping this orphans a second live engine. + expect(h.disconnects).toEqual(["datamate"]) + expect(h.toasts[0].message).toContain("not pinned to this workspace") + }) + + test("an entry pinned to ANOTHER workspace is replaced, and says which", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 7" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.toasts[0].message).toContain("pinned to workspace 7") + }) + + test("a CONNECTED url entry is replaced too — rule 4 forbids adopting hosted", async () => { + const h = install({ + existing: { type: "remote", url: "https://api.altimate.ai/sse" }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "https://api.altimate.ai/sse" }) + expect(h.added[0].cfg.type).toBe("local") + }) + + test("a matching pin is reused — no spawn, no persist", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: twoTools, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2 }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.disconnects).toHaveLength(0) // reuse must never close what it reuses + }) + + test("a recovered entry is gated too: retried back to life but unpinned, it is replaced", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(h.connects).toEqual(["datamate"]) // the one retry still happened + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio" }) + }) +}) + +describe("ensure — the version floor applies to a REUSED entry", () => { + test("a pinned entry below the floor is replaced when PATH has a newer engine", async () => { + const h = install({ + existing: { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + version: (bin) => (bin === "/opt/old/datamate" ? "0.6.3" : "0.7.0"), + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ + kind: "attached", + replaced: "/opt/old/datamate start-stdio --datamate 42", + }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("a pinned entry below the floor with nothing newer on PATH is reported, not reused", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => "0.6.3", + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.toasts[0].message).toContain(MIN_ENGINE_VERSION) + }) + + test("an entry whose binary reports no version is not trusted for reuse", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => null, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "unknown" }) + expect(h.added).toHaveLength(0) + }) +}) From ec21cc03d976584c89223fd774c14178cda2d0bf Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 00:55:37 +0800 Subject: [PATCH 03/67] fix(workspace): address codex review on the engine attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the codex review of dfb183807, all verified against the branch before fixing. **Replacing a live entry must not disable it in the config that owns it.** Teardown used `MCP.disconnect`, which persists `enabled: false` to whichever config file actually holds the entry — for an IDE-written or user-global `datamate`, that is the GLOBAL config. Our replacement is written project-local, so the user's engine stayed disabled in every OTHER project. `MCP.remove` is the right call: runtime-only teardown that closes the client, drops it from state and publishes ToolsChanged, touching no file. The reason for closing at all is unchanged — `MCP.add` does not close the client it overwrites. **The memo now follows the binding, not just the session id.** `ensure` was memoised per session, but `recordApprovedBinding` is reachable mid-session from the TUI workspace panel as well as `altimate-code link`. A session that started unbound would therefore never attach, and one re-linked to another workspace kept serving the old workspace's tools — both silently, for the rest of the session. The memo is keyed on the bound workspace, so a re-link produces a fresh attach on the next turn with its own wait budget, and an unchanged binding stays memoised. `ensure` is deliberately NOT async and registers its entry SYNCHRONOUSLY. `whenAttached` is called on the following line and looks the session up by id; an await before registration made that lookup miss, so the turn skipped the wait entirely — reintroducing the first-turn gap this module exists to close. Caught by the existing `whenAttached` tests, and now pinned by one that asserts the registration is visible immediately. **Pre-release versions no longer clear the floor.** `compareVersions` stripped the pre-release suffix, so `0.7.0-beta.1` compared equal to `0.7.0` and passed every compatibility gate. The floor exists to require behaviour that shipped in a release — the locked `--datamate` pin the attribution checks depend on — and a pre-release of that version predates it. Precedence now follows SemVer §11.3: a release outranks any pre-release of it, identifiers compare numerically where numeric, numeric ranks below alphanumeric, and a shorter identifier set ranks lower. Build metadata is ignored. Non-numeric cores still compare as older, so unreadable `--version` output can never clear a floor. 9 further tests: pre-release precedence and build metadata, an engine reporting a pre-release of the floor, re-link mid-session, unbound-then-linked, unchanged binding still memoised, and synchronous registration. --- .../src/altimate/workspace/engine-sync.ts | 121 ++++++++++++++---- .../altimate/workspace/engine-sync.test.ts | 121 ++++++++++++++++-- 2 files changed, 210 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 4e65121673..aa34c3b499 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -124,7 +124,7 @@ export const syncInternals: { status: () => Promise add: (name: string, cfg: LocalMcpConfig) => Promise connect: (name: string) => Promise - disconnect: (name: string) => Promise + remove: (name: string) => Promise tools: () => Promise> } persist?: (name: string, cfg: LocalMcpConfig) => Promise @@ -138,23 +138,56 @@ export function isEnabled(): boolean { return CoreFlag.ALTIMATE_WORKSPACE } -/** Numeric semver compare on the `major.minor.patch` core; pre-release tags - * are ignored. Returns <0, 0, >0. Non-numeric input compares as older. */ +/** SemVer precedence compare. Returns <0, 0, >0. + * + * Build metadata is ignored, and a NON-numeric core component compares as older + * so unreadable `--version` output can never clear a floor. + * + * Pre-release ordering is honoured rather than stripped: `0.7.0-beta.1` is + * BELOW `0.7.0`. That matters here — the floor exists to require behaviour that + * shipped in a specific release (the locked `--datamate` pin), and a pre-release + * of that version predates it. Treating them as equal let a beta clear the floor + * and be trusted for reuse. */ export function compareVersions(a: string, b: string): number { - const parse = (v: string) => - v - .trim() - .replace(/^v/, "") - .split("-")[0] - .split(".") - .map((n) => Number.parseInt(n, 10)) - const pa = parse(a) - const pb = parse(b) + const split = (v: string) => { + const bare = v.trim().replace(/^v/, "") + const plus = bare.indexOf("+") + const noBuild = plus >= 0 ? bare.slice(0, plus) : bare + const dash = noBuild.indexOf("-") + return { + core: (dash >= 0 ? noBuild.slice(0, dash) : noBuild).split(".").map((n) => Number.parseInt(n, 10)), + pre: dash >= 0 ? noBuild.slice(dash + 1) : "", + } + } + const pa = split(a) + const pb = split(b) for (let i = 0; i < 3; i++) { - const x = Number.isFinite(pa[i]) ? pa[i] : -1 - const y = Number.isFinite(pb[i]) ? pb[i] : -1 + const x = Number.isFinite(pa.core[i]) ? pa.core[i] : -1 + const y = Number.isFinite(pb.core[i]) ? pb.core[i] : -1 if (x !== y) return x - y } + // Same core: a release outranks every pre-release of it (SemVer §11.3). + if (!pa.pre && !pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + const ia = pa.pre.split(".") + const ib = pb.pre.split(".") + for (let i = 0; i < Math.max(ia.length, ib.length); i++) { + const x = ia[i] + const y = ib[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + const nx = /^\d+$/.test(x) + const ny = /^\d+$/.test(y) + if (nx && ny) { + const d = Number(x) - Number(y) + if (d !== 0) return d + } else if (nx !== ny) { + return nx ? -1 : 1 + } else if (x !== y) { + return x < y ? -1 : 1 + } + } return 0 } @@ -220,7 +253,7 @@ function mcp() { status: () => MCP.status() as Promise, add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), connect: (name: string) => MCP.connect(name), - disconnect: (name: string) => MCP.disconnect(name), + remove: (name: string) => MCP.remove(name), tools: () => MCP.tools() as Promise>, } ) @@ -486,7 +519,14 @@ async function run(): Promise { // Close the live registration first: `MCP.add` does not close the client it // overwrites, so adding over a running stdio server starts a second engine // and abandons the first with its pipes still open. - await client.disconnect(DATAMATE_KEY).catch((err) => { + // + // `remove`, NOT `disconnect`. `MCP.disconnect` persists `enabled: false` to + // whichever config file actually holds the entry — which for an IDE-written + // or user-global `datamate` is the GLOBAL config. Our replacement is written + // project-locally, so disconnecting would leave the user's engine disabled in + // every OTHER project. `remove` is runtime-only teardown: it closes the + // client, drops it from state, and publishes ToolsChanged, touching no file. + await client.remove(DATAMATE_KEY).catch((err) => { log.warn("could not close the engine entry being replaced", { err: String(err) }) }) } @@ -538,14 +578,51 @@ async function run(): Promise { * answers costs the first turn a pause rather than the turn itself. */ export const ATTACH_WAIT_MS = 15_000 -type SessionAttach = { task: Promise; waitTimedOut?: boolean } +type SessionAttach = { key?: string; task: Promise; waitTimedOut?: boolean } const sessions = new Map() -export async function ensure(sessionID: string): Promise { - const existing = sessions.get(sessionID) - if (existing) return existing.task - const task = run() +/** What a memoised attach is valid FOR. + * + * Memoising on the session id alone was wrong: the binding can change while a + * session is open — `recordApprovedBinding` is reachable mid-session from the + * TUI workspace panel as well as from `altimate-code link`. A session that + * started unbound would then never attach, and one that was re-linked to another + * workspace would keep serving the old workspace's tools, both silently and for + * the rest of the session. Keying on the bound workspace makes a re-link produce + * a fresh attach on the next turn and leaves everything else memoised as before. */ +async function attachKey(): Promise { + if (!isEnabled()) return "disabled" + const binding = await resolveBinding() + return binding ? `workspace:${binding.datamateId}` : "unbound" +} + +export function ensure(sessionID: string): Promise { + // NOT async, and the entry is registered SYNCHRONOUSLY. `whenAttached` is + // called on the line after this one and looks the session up by id; if the + // registration happened after an await, that lookup would find nothing and the + // turn would sail past without waiting — which is exactly the first-turn gap + // this module exists to close. All the async work happens inside the task. + const previous = sessions.get(sessionID) + const entry = { key: previous?.key, waitTimedOut: previous?.waitTimedOut } as SessionAttach + entry.task = (async (): Promise => { + const key = await attachKey() + // Same workspace as the attach we already did for this session: reuse it. + if (previous && previous.key === key) return previous.task + // First attach for this session, or the binding changed under it. A changed + // binding gets a fresh attach AND a fresh wait budget: the previous budget + // was spent on a different workspace's engine. + entry.key = key + entry.waitTimedOut = false + return attachOnce(sessionID) + })() + sessions.set(sessionID, entry) + return entry.task +} + +/** One attach, with the outcome logged exactly once. */ +function attachOnce(sessionID: string): Promise { + return run() .catch((err): Outcome => { log.warn("workspace engine attach failed", { err: String(err) }) return { kind: "connect-failed", error: String(err) } @@ -556,8 +633,6 @@ export async function ensure(sessionID: string): Promise { log.info("workspace engine outcome", { sessionID, ...outcome }) return outcome }) - sessions.set(sessionID, { task }) - return task } /** Wait for a session's in-flight attach, capped. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index c05f5d5cf3..bd7ca9b6e4 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -32,7 +32,7 @@ type Harness = { added: Array<{ name: string; cfg: LocalMcpConfig }> persisted: Array<{ name: string; cfg: LocalMcpConfig }> connects: string[] - disconnects: string[] + removes: string[] toasts: Array<{ title: string; message: string; variant: string }> statusQueue: Array> tools: Record @@ -51,7 +51,7 @@ function install(opts: { added: [], persisted: [], connects: [], - disconnects: [], + removes: [], toasts: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, @@ -79,8 +79,8 @@ function install(opts: { connect: async (name) => { h.connects.push(name) }, - disconnect: async (name) => { - h.disconnects.push(name) + remove: async (name) => { + h.removes.push(name) }, tools: async () => h.tools, } @@ -234,7 +234,7 @@ describe("ensure", () => { replaced: "http://localhost:7801/sse", }) expect(h.connects).toHaveLength(0) // no pointless retry of a dead port - expect(h.disconnects).toHaveLength(0) // a dead URL has nothing live to close + expect(h.removes).toHaveLength(0) // a dead URL has nothing live to close expect(h.added).toHaveLength(1) expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) expect(h.toasts[0].message).toContain("Replaced the unreachable engine URL http://localhost:7801/sse") @@ -363,9 +363,11 @@ describe("ensure — attribution of a CONNECTED entry", () => { }) // The replacement is a pinned spawn, so the engine we end up on is ours. expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) - // ...and the live one it displaced was CLOSED first. `MCP.add` does not close - // the client it overwrites, so skipping this orphans a second live engine. - expect(h.disconnects).toEqual(["datamate"]) + // ...and the live one it displaced was torn down first. `MCP.add` does not + // close the client it overwrites, so skipping this orphans a second live + // engine. It must be `remove` (runtime-only), never `disconnect`, which + // would persist `enabled: false` into the config that owns the entry. + expect(h.removes).toEqual(["datamate"]) expect(h.toasts[0].message).toContain("not pinned to this workspace") }) @@ -401,7 +403,7 @@ describe("ensure — attribution of a CONNECTED entry", () => { expect(await ensure("s1")).toEqual({ kind: "reused", available: 2 }) expect(h.added).toHaveLength(0) expect(h.persisted).toHaveLength(0) - expect(h.disconnects).toHaveLength(0) // reuse must never close what it reuses + expect(h.removes).toHaveLength(0) // reuse must never tear down what it reuses }) test("a recovered entry is gated too: retried back to life but unpinned, it is replaced", async () => { @@ -458,3 +460,104 @@ describe("ensure — the version floor applies to a REUSED entry", () => { expect(h.added).toHaveLength(0) }) }) + +describe("compareVersions — pre-release precedence (SemVer §11.3)", () => { + test("a pre-release of the floor version does NOT clear the floor", () => { + // The floor exists to require behaviour that shipped in a release; a + // pre-release of that version predates it, so it must rank below. + expect(compareVersions("0.7.0-beta.1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("0.7.0", "0.7.0-beta.1")).toBeGreaterThan(0) + expect(compareVersions("0.7.0-beta.1", MIN_ENGINE_VERSION)).toBeLessThan(0) + }) + test("identifiers order by SemVer rules", () => { + expect(compareVersions("0.7.0-alpha", "0.7.0-beta")).toBeLessThan(0) + expect(compareVersions("0.7.0-beta.2", "0.7.0-beta.10")).toBeLessThan(0) // numeric, not lexical + expect(compareVersions("0.7.0-alpha", "0.7.0-alpha.1")).toBeLessThan(0) // fewer fields rank lower + expect(compareVersions("0.7.0-alpha.1", "0.7.0-alpha.beta")).toBeLessThan(0) // numeric < alphanumeric + expect(compareVersions("0.7.0-beta.1", "0.7.0-beta.1")).toBe(0) + }) + test("build metadata is ignored", () => { + expect(compareVersions("0.7.0+build.5", "0.7.0")).toBe(0) + expect(compareVersions("0.8.0+x", "0.7.0")).toBeGreaterThan(0) + }) + test("a release still outranks an older release", () => { + expect(compareVersions("0.7.1", "0.7.0")).toBeGreaterThan(0) + expect(compareVersions("0.6.9", "0.7.0")).toBeLessThan(0) + }) +}) + +describe("ensure — pre-release engines are refused", () => { + test("an engine reporting a pre-release of the floor is too old", async () => { + const h = install({ version: "0.7.0-beta.1" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.7.0-beta.1" }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) +}) + +describe("ensure — the memo follows the BINDING, not just the session id", () => { + const spawnTwice: Harness["statusQueue"] = [ + {}, + { datamate: { status: "connected" } }, + {}, + { datamate: { status: "connected" } }, + ] + + test("a re-link mid-session attaches the NEW workspace", async () => { + // recordApprovedBinding is reachable mid-session from the TUI workspace + // panel, so a live session's binding really can change under it. + let current: CachedBinding | null = binding // datamate 42 + const h = install({ statuses: spawnTwice, tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + const second = await ensure("s1") + expect(second).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(2) + expect(h.added[1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + + test("a session that starts UNBOUND attaches once the project is linked", async () => { + let current: CachedBinding | null = null + const h = install({ statuses: spawnTwice, tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toHaveLength(0) + + current = binding + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("an unchanged binding is still memoised — no second attach per turn", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const first = await ensure("s1") + const second = await ensure("s1") + const third = await ensure("s1") + expect(second).toBe(first) + expect(third).toBe(first) + expect(h.added).toHaveLength(1) + expect(h.persisted).toHaveLength(1) + }) + + test("registration is SYNCHRONOUS, so whenAttached on the next line sees it", async () => { + // ensure() must not await before registering: prompt.ts calls whenAttached + // immediately after, and a late registration would make the turn skip the + // wait entirely — the exact first-turn gap this module closes. + const h = install({ tools: { datamate_dbt_build_model: 1 } }) + syncInternals.versionOf = () => new Promise(() => {}) // never settles + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 30) + expect(performance.now() - started).toBeGreaterThanOrEqual(20) + expect(h).toBeDefined() + }) +}) From 248043922cafd0e783e44a685556b895d4e1d204 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 01:22:43 +0800 Subject: [PATCH 04/67] fix(workspace): detach rejected engines, and report the gap on reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 2 on ec21cc03d. Three findings, all verified against the branch. **A rejected engine is now detached at the moment of rejection.** Teardown ran just before the replacement spawn, so every exit that failed to produce a replacement — `engine-missing`, and both `engine-too-old` returns — came back with the rejected engine still connected. The turn's `resolveTools` then handed the model exactly the tools the attribution check had just decided it must not have: an unpinned engine serving whichever workspace its owner had active, or one below the floor whose `--datamate` pin the engine does not lock. Worse than the pre-attribution behaviour, because the client had explicitly judged it untrustworthy and served it anyway. `detachRejected` now runs at each rejection site, so "we will not use this engine" and "this engine is no longer serving this session" are the same event. It stays runtime-only (`MCP.remove`): the config file is never touched. **Reuse reports declared-versus-delivered, like the fresh attach.** A running engine that lost an integration — a deleted connection, a restart that dropped one — serves fewer tools than the workspace declares, and only the fresh-attach path said so. Reuse is the common path, so silence there is exactly where the gap goes unnoticed; it was visible in this branch's own testing, where a workspace declaring 52 keys delivered 12 and then 7 across a connection change. `reused` now carries `declared` and `missing` and warns when the gap is non-empty. An unreadable allowlist degrades quietly rather than inventing a gap. **An unbound project no longer keeps a stale managed entry.** MCP bootstrap starts every enabled config entry before the prompt (`src/mcp/index.ts:762-781`), while `run()` returned for an unbound project without touching it — so unlinking a project left the previously pinned workspace's tools still attached. Only an entry matching the exact command we persist is torn down; an IDE-written or hand-edited entry is the user's and is left alone, and the config is not modified either way. The related half of that finding — a project attached under the pilot flag keeps its tools when the flag is later off — is NOT fixed here. Acting on it means doing MCP work while the gate is closed, which is the opposite of what the gate is for, and deciding whether a pilot flag should retroactively disable an entry the user now has in their project config is a product call. Recorded in the PR body under "Held for Ralph" instead of guessed at. 9 further tests: detach on each irreplaceable-rejection exit, the reuse gap and its toast, an unreadable allowlist, and unbound detaching our managed entry while leaving an IDE entry alone. --- .../src/altimate/workspace/engine-sync.ts | 115 +++++++++++++----- .../altimate/workspace/engine-sync.test.ts | 114 ++++++++++++++++- 2 files changed, 197 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index aa34c3b499..dc118f9101 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -92,7 +92,7 @@ const TOOL_PREFIX = `${DATAMATE_KEY}_` export type Outcome = | { kind: "disabled" } | { kind: "unbound" } - | { kind: "reused"; available: number } + | { kind: "reused"; available: number; declared?: number; missing?: string[] } | { kind: "attached"; available: number; declared: number; missing: string[]; replaced?: string } | { kind: "engine-missing"; declared: number } | { kind: "engine-too-old"; found: string } @@ -312,6 +312,14 @@ export function pinnedWorkspace(entry: ExistingEntry | null): string | null { return null } +/** Did WE write this entry? Only an entry matching the exact command we persist + * is ours to tear down; an IDE-written or hand-edited entry belongs to the user + * and is left alone even when it is the wrong one for this project. */ +function isManagedEntry(entry: ExistingEntry | null): boolean { + const argv = commandArgv(entry) + return argv.length === 4 && argv[0] === ENGINE_BINARY && argv[1] === "start-stdio" && argv[2] === PIN_FLAG && !!argv[3] +} + /** Short, printable identity of an entry, for saying what was replaced. */ function describeEntry(entry: ExistingEntry | null): string { if (isUrlEntry(entry)) return entry.url @@ -366,10 +374,28 @@ function describeMissing(missing: string[]): string { async function run(): Promise { if (!isEnabled()) return { kind: "disabled" } + const client = mcp() + const binding = await resolveBinding() - if (!binding) return { kind: "unbound" } + if (!binding) { + // An entry WE persisted for a binding that no longer exists is still started + // by MCP bootstrap on every launch, and would serve the OLD workspace's tools + // in a project that is no longer linked to it. Detach it — runtime only, the + // config entry is left in place — so an unlinked project does not silently + // keep another workspace's tools. Only our own managed entry qualifies. + const present = (await client.status())[DATAMATE_KEY] + if (present) { + const stale = await existingEntry(DATAMATE_KEY) + if (isManagedEntry(stale)) { + log.info("detaching a managed engine entry in an unbound project", { entry: describeEntry(stale) }) + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the managed engine entry", { err: String(err) }) + }) + } + } + return { kind: "unbound" } + } const workspaceId = String(binding.datamateId) - const client = mcp() // Rule 1 — reuse what already serves this session, but only if it can be shown // to serve THIS workspace, on an engine that still clears the floor. @@ -385,11 +411,25 @@ async function run(): Promise { // locked, which is the drift this attribution is meant to exclude. let replaced: string | undefined let replacedNote = "" - /** Was the entry we are replacing still RUNNING? Then it must be closed before - * we spawn, or `MCP.add` leaves its child process orphaned beside the new one — - * the same duplicate-engine hazard this module refuses for a failing entry, and - * in practice it wedges the session. */ - let replacedLive = false + + /** Stop serving an entry we have judged untrustworthy for this workspace. + * + * Runtime-only (`MCP.remove`): closes the client and drops it from the tool + * catalogue without touching any config file — `MCP.disconnect` would persist + * `enabled: false` into whichever config owns the entry, which for a global + * one disables the user's engine everywhere. + * + * This must run at the moment of REJECTION, not merely before a replacement + * spawn. Every exit that fails to produce a replacement — `engine-missing`, + * `engine-too-old` — would otherwise return with the rejected engine still + * connected, and the turn's `resolveTools` would hand the model exactly the + * tools we just decided it must not have. It also closes the client `MCP.add` + * would otherwise overwrite without closing, which orphans a second engine. */ + const detachRejected = async (why: Record): Promise => { + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) + }) + } const before = await client.status() const existing = before[DATAMATE_KEY] if (existing) { @@ -436,22 +476,49 @@ async function run(): Promise { // connected URL entry lands here too, which is the point — the hosted // endpoint serves a different tool set, and rule 4 forbids adopting it. replaced = describeEntry(entry) - replacedLive = true replacedNote = pin ? ` Replaced an engine entry pinned to workspace ${pin} for this session.` : ` Replaced an engine entry that is not pinned to this workspace (${replaced}) for this session; it serves whichever workspace its owner has active.` - log.info("existing engine entry is not attributable to this workspace; will spawn locally", { + log.info("existing engine entry is not attributable to this workspace; detaching", { workspaceId, pinnedTo: pin, entry: replaced, }) + await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: pin }) } else { const entryBin = commandArgv(entry)[0] const found = entryBin ? await versionOf(entryBin) : null if (found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) { - const available = engineToolKeys(await client.tools()).size - log.info("reusing existing engine entry", { workspaceId, available, version: found }) - return { kind: "reused", available } + // Rule 5 applies to a reused engine too. A running engine that lost an + // integration — a connection deleted, a restart that dropped it — + // serves fewer tools than the workspace declares, and only the fresh + // attach used to say so. Reuse is the COMMON path, so staying silent + // here is where the gap would actually go unnoticed. + const present = engineToolKeys(await client.tools()) + const declaredKeys = await declared(workspaceId) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + if (declaredKeys && missing.length > 0) { + await notify({ + title: `Workspace "${binding.datamateName}" is missing declared tools`, + message: + `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + + describeMissing(missing), + variant: "warning", + }) + } + log.info("reusing existing engine entry", { + workspaceId, + available, + version: found, + declared: declaredKeys?.keys.length, + missing, + }) + return { + kind: "reused", + available, + ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), + } } // Pinned to us, but below the floor or unreadable. Prefer a newer engine // on PATH over keeping one whose pin the engine does not lock; if PATH @@ -460,6 +527,9 @@ async function run(): Promise { const pathVersion = onPath ? await versionOf(onPath) : null if (!pathVersion || compareVersions(pathVersion, MIN_ENGINE_VERSION) < 0) { const label = found ?? "unknown" + // Rejected and irreplaceable: detach anyway. Leaving it connected would + // return "too old" while still serving the too-old engine's tools. + await detachRejected({ workspaceId, reason: "below-floor", found: label }) await notify({ title: "Workspace engine is too old", message: @@ -470,13 +540,13 @@ async function run(): Promise { return { kind: "engine-too-old", found: label } } replaced = describeEntry(entry) - replacedLive = true replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` - log.info("existing engine entry is below the version floor; will spawn locally", { + log.info("existing engine entry is below the version floor; detaching", { workspaceId, found, pathVersion, }) + await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }) } } } @@ -515,21 +585,6 @@ async function run(): Promise { command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], enabled: true, } - if (replacedLive) { - // Close the live registration first: `MCP.add` does not close the client it - // overwrites, so adding over a running stdio server starts a second engine - // and abandons the first with its pipes still open. - // - // `remove`, NOT `disconnect`. `MCP.disconnect` persists `enabled: false` to - // whichever config file actually holds the entry — which for an IDE-written - // or user-global `datamate` is the GLOBAL config. Our replacement is written - // project-locally, so disconnecting would leave the user's engine disabled in - // every OTHER project. `remove` is runtime-only teardown: it closes the - // client, drops it from state, and publishes ToolsChanged, touching no file. - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not close the engine entry being replaced", { err: String(err) }) - }) - } await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index bd7ca9b6e4..cf54b6bf97 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -140,7 +140,7 @@ describe("ensure", () => { statuses: [{ datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, }) - expect(await ensure("s1")).toEqual({ kind: "reused", available: 2 }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2, declared: 2, missing: [] }) expect(h.added).toHaveLength(0) expect(h.persisted).toHaveLength(0) }) @@ -400,7 +400,7 @@ describe("ensure — attribution of a CONNECTED entry", () => { statuses: [{ datamate: { status: "connected" } }], tools: twoTools, }) - expect(await ensure("s1")).toEqual({ kind: "reused", available: 2 }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2, declared: 2, missing: [] }) expect(h.added).toHaveLength(0) expect(h.persisted).toHaveLength(0) expect(h.removes).toHaveLength(0) // reuse must never tear down what it reuses @@ -561,3 +561,113 @@ describe("ensure — the memo follows the BINDING, not just the session id", () expect(h).toBeDefined() }) }) + +describe("ensure — a REJECTED engine is detached even when it cannot be replaced", () => { + const liveUnpinned = { type: "local", command: ["datamate", "start-stdio"] } + + test("no engine on PATH: still detaches, so resolveTools cannot serve the wrong workspace", async () => { + const h = install({ + existing: liveUnpinned, + statuses: [{ datamate: { status: "connected" } }], + which: null, + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + // The whole point: we judged it untrustworthy, so it must not still be serving. + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("PATH engine below the floor: still detaches before reporting too-old", async () => { + const h = install({ + existing: liveUnpinned, + statuses: [{ datamate: { status: "connected" } }], + version: "0.5.9", + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("a pinned-but-below-floor engine with nothing better is detached, not left serving", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => "0.6.3", + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.removes).toEqual(["datamate"]) + }) +}) + +describe("ensure — reuse reports the declared-vs-delivered gap (rule 5)", () => { + test("a reused engine missing a declared tool warns, and the outcome carries the gap", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, // declared has two keys + }) + expect(await ensure("s1")).toEqual({ + kind: "reused", + available: 1, + declared: 2, + missing: ["dbt_compile_model"], + }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain("1 of 2 declared integration tools") + expect(h.toasts[0].message).toContain("dbt_compile_model") + }) + + test("no gap means no toast", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "reused", missing: [] }) + expect(h.toasts).toHaveLength(0) + }) + + test("an unreadable allowlist degrades quietly rather than inventing a gap", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + declared: null, + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 1 }) + expect(h.toasts).toHaveLength(0) + }) +}) + +describe("ensure — an unbound project does not keep a stale MANAGED entry", () => { + test("our own pinned entry is detached when the binding is gone", async () => { + const h = install({ + binding: null, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toEqual(["datamate"]) + }) + + test("an IDE-written entry is LEFT ALONE — it is the user's, not ours", async () => { + const h = install({ + binding: null, + existing: { command: "datamate", args: ["start-stdio"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) + }) + + test("nothing registered means nothing to detach", async () => { + const h = install({ binding: null, statuses: [{}] }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) + expect(h.toasts).toHaveLength(0) + }) +}) From 37714b4bcd95a79f23d47151ace69fec76bc15d4 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 01:57:06 +0800 Subject: [PATCH 05/67] fix(workspace): close four attach races found by codex round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four verified against the branch before fixing. **A superseded attach can no longer overwrite the current one.** Re-linking a live session started a second attach without ordering it against the first. Both end in `MCP.add`, and whichever completes last owns the runtime client, so a slower attach for the workspace just left could land after the new one and restore its tools — with the new memo already settled, so no later turn would repair it. Replacement attaches are now serialized per session: the superseded task is awaited before the next one starts, which makes the final `MCP.add` the current workspace's by construction. **Persisting the entry now invalidates the config cache.** `Config.get()` is cached per instance and `addMcpToConfig` is a raw file write that does not touch that cache, so every later `existingEntry()` in the process still saw the pre-write config. A managed entry then became unrecognisable to `isManagedEntry`, which is what leaves a stale engine attached in a project whose binding stopped resolving — the failure mode the previous commit's unbound teardown was supposed to prevent. The local-config write path in `config.ts` already invalidates for this exact reason; the engine entry now does too. **A repairable failure is re-probed on the next turn.** `engine-missing`, `engine-too-old` and `connect-failed` were memoised for the life of the session, so a user who followed the install hint we had just printed saw nothing happen until they started a new session. Those three outcomes are now retried; success stays memoised, so this does not mean re-attaching every turn. The retry deliberately does NOT re-arm the bounded wait: it runs on every turn, and a `connect-failed` retry sitting in MCP's 30s connect budget would otherwise charge each turn the full cap. Repaired tools arrive over `tools/list_changed`. **The version probe targets the engine, not its wrapper.** For a pinned entry like `npx @altimateai/datamate@0.6.3 start-stdio --datamate 42`, the probe ran `npx --version`, so a modern wrapper vouched for a pre-floor engine that does not provide the locked workspace pin attribution relies on. Only a directly identifiable `datamate` executable is probed now; anything else yields no version and falls through to the below-floor handling — replaced from PATH, or reported. Asking the running server instead is not an option: `serverInfo.version` is a hard-coded placeholder on precisely the engines this floor excludes. 7 further tests: retry after install and after update, success still memoised, the retry not re-arming the wait, npx not probed and replaced, an absolute datamate path probed and reused, and a slow superseded attach losing to the re-linked workspace. --- .../src/altimate/workspace/engine-sync.ts | 65 +++++++-- .../altimate/workspace/engine-sync.test.ts | 129 ++++++++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index dc118f9101..c0c881df37 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -263,6 +263,15 @@ async function persist(name: string, cfg: LocalMcpConfig): Promise { if (syncInternals.persist) return syncInternals.persist(name, cfg) const configPath = await resolveConfigPath(projectRoot()) await addMcpToConfig(name, cfg, configPath) + // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file + // write that does not touch that cache — so without this, every later + // `existingEntry()` in this process still sees the pre-write config. That is + // how a managed entry becomes unrecognisable to `isManagedEntry` later in the + // same server process, leaving a stale engine attached in an unbound project. + // The local-config write path in `config.ts` invalidates for the same reason. + await Config.invalidate().catch((err) => { + log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) + }) } async function existingEntry(name: string): Promise { @@ -486,8 +495,16 @@ async function run(): Promise { }) await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: pin }) } else { + // Probe the ENGINE, not whatever wraps it. `npx @altimateai/datamate@0.6.3 + // start-stdio --datamate 42` would otherwise have us run `npx --version` + // and let a pre-floor engine clear the floor on the wrapper's version. + // Asking the running server instead is not an option: `serverInfo.version` + // is a hard-coded placeholder on the very engines this floor excludes. + // An unidentifiable command yields no version, which falls through to the + // below-floor handling — replace it from PATH, or report it. const entryBin = commandArgv(entry)[0] - const found = entryBin ? await versionOf(entryBin) : null + const directBin = entryBin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(entryBin) ? entryBin : null + const found = directBin ? await versionOf(directBin) : null if (found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) { // Rule 5 applies to a reused engine too. A running engine that lost an // integration — a connection deleted, a restart that dropped it — @@ -633,7 +650,17 @@ async function run(): Promise { * answers costs the first turn a pause rather than the turn itself. */ export const ATTACH_WAIT_MS = 15_000 -type SessionAttach = { key?: string; task: Promise; waitTimedOut?: boolean } +type SessionAttach = { key?: string; task: Promise; waitTimedOut?: boolean; outcome?: Outcome } + +/** Outcomes the user can repair without restarting: install the engine, update + * it, fix a broken entry. Caching these for the life of the session means the + * hint we just printed ("install it with …") can be followed and nothing + * happens until a new session — so they are re-probed on the next turn. */ +const REPAIRABLE = new Set(["engine-missing", "engine-too-old", "connect-failed"]) + +function isRepairable(outcome: Outcome | undefined): boolean { + return !!outcome && REPAIRABLE.has(outcome.kind) +} const sessions = new Map() @@ -662,15 +689,37 @@ export function ensure(sessionID: string): Promise { const entry = { key: previous?.key, waitTimedOut: previous?.waitTimedOut } as SessionAttach entry.task = (async (): Promise => { const key = await attachKey() - // Same workspace as the attach we already did for this session: reuse it. - if (previous && previous.key === key) return previous.task - // First attach for this session, or the binding changed under it. A changed - // binding gets a fresh attach AND a fresh wait budget: the previous budget - // was spent on a different workspace's engine. + const sameWorkspace = !!previous && previous.key === key + // Same workspace and the attach either succeeded or is still in flight: + // reuse it. A settled FAILURE is not reused — the user may have acted on + // the hint it produced. + if (sameWorkspace && !isRepairable(previous!.outcome)) return previous!.task entry.key = key - entry.waitTimedOut = false + if (sameWorkspace) { + // Re-probing a repairable failure. Do NOT re-arm the wait: this runs on + // every turn, and a retry that blocks would charge each one the full cap + // (a `connect-failed` retry can sit in MCP's 30s connect budget). The + // repaired engine's tools arrive over `tools/list_changed` instead. + entry.waitTimedOut = true + } else { + // The binding changed under this session. A fresh attach gets a fresh wait + // budget — the previous one was spent on a different workspace's engine. + entry.waitTimedOut = false + // Serialize against the attach being superseded. Both tasks end in + // `MCP.add`, and whichever completes LAST owns the runtime client, so a + // slower attach for the workspace we just left could otherwise land after + // this one and restore its tools — with this session's memo already + // settled, so no later turn would repair it. + if (previous) await previous.task.catch(() => {}) + } return attachOnce(sessionID) })() + entry.task.then( + (outcome) => { + entry.outcome = outcome + }, + () => {}, + ) sessions.set(sessionID, entry) return entry.task } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index cf54b6bf97..5e6d089f09 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -671,3 +671,132 @@ describe("ensure — an unbound project does not keep a stale MANAGED entry", () expect(h.toasts).toHaveLength(0) }) }) + +describe("ensure — a repairable failure is re-probed on the next turn", () => { + test("engine-missing is retried once the engine appears, without a new session", async () => { + let onPath: string | null = null + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.which = () => onPath + + // Turn 1: no engine. We print the install hint. + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.added).toHaveLength(0) + + // The user follows that hint mid-session. + onPath = "/usr/local/bin/datamate" + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("engine-too-old is retried after an update", async () => { + let version = "0.5.9" + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async () => version + + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + version = "0.7.0" + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("a SUCCESSFUL outcome is still memoised — retry must not mean re-attach every turn", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(await ensure("s1")).toBe(first) + expect(await ensure("s1")).toBe(first) + expect(h.added).toHaveLength(1) + }) + + test("a repairable retry does not re-arm the turn wait", async () => { + install({ which: null }) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + // Next turn re-probes, but whenAttached must return immediately rather than + // charging this turn the full cap. + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(100) + }) +}) + +describe("ensure — the version probe targets the engine, not its wrapper", () => { + test("an npx-wrapped entry is not trusted on the wrapper's version", async () => { + const probed: string[] = [] + const h = install({ + existing: { type: "local", command: ["npx", "@altimateai/datamate@0.6.3", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async (bin) => { + probed.push(bin) + return "0.7.0" + } + const outcome = await ensure("s1") + // npx is never probed — a modern wrapper must not vouch for an old engine. + expect(probed).not.toContain("npx") + // Unverifiable, so it is replaced by a pinned spawn we can vouch for. + expect(outcome).toMatchObject({ kind: "attached" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("an absolute path to a real datamate IS probed and reused", async () => { + const probed: string[] = [] + const h = install({ + existing: { type: "local", command: ["/opt/bin/datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async (bin) => { + probed.push(bin) + return "0.7.0" + } + expect(await ensure("s1")).toMatchObject({ kind: "reused" }) + expect(probed).toContain("/opt/bin/datamate") + expect(h.added).toHaveLength(0) + }) +}) + +describe("ensure — a superseded attach cannot overwrite the current one", () => { + test("the re-linked workspace wins even when the old attach is slower", async () => { + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [ + {}, + { datamate: { status: "connected" } }, + {}, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + // Make the FIRST attach slow, so without serialization it would land last. + let firstAdd = true + syncInternals.mcp!.add = async (name, cfg) => { + if (firstAdd) { + firstAdd = false + await new Promise((r) => setTimeout(r, 60)) + } + h.added.push({ name, cfg }) + } + + const a = ensure("s1") // workspace 42 + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + const b = ensure("s1") // re-link to 99 + await Promise.all([a, b]) + + // Both ran, but in order: the LAST add must be the workspace we re-linked to. + expect(h.added).toHaveLength(2) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) +}) From a84e7c39307be03f5ca17cb08ccef09d8e365fe6 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 02:17:41 +0800 Subject: [PATCH 06/67] fix(workspace): respect a disabled entry, and serialize attaches per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 4 on 37714b4bc. Two findings, both verified, and both new tests were confirmed to fail against the unfixed code before the fix was written. **An explicitly disabled entry is no longer silently re-enabled.** A configured entry with `enabled: false` is reported by `MCP.status()` as `disabled`, which this flow read as "not connected" and retried with `MCP.connect`. That call persists `enabled: true` into whichever config file owns the entry, so for a global `datamate` the first prompt in any bound project quietly re-enabled it for every other project — the same class as the round-1 teardown finding, in the opposite direction. A disabled entry is now left alone and reported as such; only a genuinely `failed` entry is retried. **Attaches are serialized per project, not merely per session.** The previous commit ordered replacement attaches within a session, but the MCP client is instance-wide, `MCP.add` is last-writer-wins, and `SessionRunState` keeps independent runners per session id — so two prompts in the same project overlap for real. A slower attach from one session could land after another's and leave the runtime serving a workspace nobody is bound to, with both memos settled so no later turn would repair it. Attaches now run through a per-project chain, which also subsumes the per-session ordering. `entry-disabled` joins the repairable outcomes, so enabling the entry mid-session is picked up on the next turn rather than requiring a new session. 3 further tests: a disabled entry is neither connected nor persisted, a failed entry is still retried exactly once, and two overlapping sessions in one project never hold the mutating phase at the same time. The last of these asserts the invariant directly (peak concurrency of 1) after two attempts that passed against the unfixed code and so proved nothing. --- .../src/altimate/workspace/engine-sync.ts | 59 ++++++++++++++++++- .../altimate/workspace/engine-sync.test.ts | 53 +++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index c0c881df37..73e33033ab 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -97,6 +97,7 @@ export type Outcome = | { kind: "engine-missing"; declared: number } | { kind: "engine-too-old"; found: string } | { kind: "connect-failed"; error: string } + | { kind: "entry-disabled" } export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } @@ -446,6 +447,22 @@ async function run(): Promise { let connected = existing.status === "connected" if (!connected) { + if (existing.status === "disabled") { + // The user turned this entry off deliberately. Do NOT call `MCP.connect` + // to "retry" it: that persists `enabled: true` into whichever config + // owns the entry, so for a global `datamate` the first prompt in any + // bound project would silently re-enable it for every other project. + // Say what is unavailable and leave their choice alone. + log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) + await notify({ + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }) + return { kind: "entry-disabled" } + } if (isUrlEntry(entry)) { // Dead URL: nothing here can bring that process back — only the IDE can // restore its port. Fall through to a local spawn and report it below. @@ -656,7 +673,7 @@ type SessionAttach = { key?: string; task: Promise; waitTimedOut?: bool * it, fix a broken entry. Caching these for the life of the session means the * hint we just printed ("install it with …") can be followed and nothing * happens until a new session — so they are re-probed on the next turn. */ -const REPAIRABLE = new Set(["engine-missing", "engine-too-old", "connect-failed"]) +const REPAIRABLE = new Set(["engine-missing", "engine-too-old", "connect-failed", "entry-disabled"]) function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) @@ -724,9 +741,44 @@ export function ensure(sessionID: string): Promise { return entry.task } -/** One attach, with the outcome logged exactly once. */ +/** In-flight attach chain per project. + * + * Per-session ordering is not enough: the MCP client is instance-wide, not per + * session, `MCP.add` is last-writer-wins, and `SessionRunState` keeps + * independent runners per session id — so two prompts in the same project + * genuinely overlap. Without this, a slower attach from one session can land + * after another session's and leave the runtime serving a workspace nobody is + * bound to, with both memos settled so no later turn repairs it. */ +const attachChains = new Map>() + +function projectKey(): string { + try { + return projectRoot() + } catch { + return "" + } +} + +function serializeAttach(fn: () => Promise): Promise { + const key = projectKey() + const previous = attachChains.get(key) ?? Promise.resolve() + // Run regardless of how the previous attach ended — a failure must not wedge + // the chain for the rest of the process. + const next = previous.then(fn, fn) + attachChains.set( + key, + next.then( + () => {}, + () => {}, + ), + ) + return next +} + +/** One attach, serialized against every other attach in this project, with the + * outcome logged exactly once. */ function attachOnce(sessionID: string): Promise { - return run() + return serializeAttach(() => run()) .catch((err): Outcome => { log.warn("workspace engine attach failed", { err: String(err) }) return { kind: "connect-failed", error: String(err) } @@ -783,4 +835,5 @@ export async function whenAttached(sessionID: string, timeoutMs: number = ATTACH /** Test seam — drop memoised outcomes. */ export function resetForTests(): void { sessions.clear() + attachChains.clear() } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 5e6d089f09..13ed5e9b8d 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -800,3 +800,56 @@ describe("ensure — a superseded attach cannot overwrite the current one", () = expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) }) }) + +describe("ensure — round 4", () => { + test("an explicitly disabled entry is respected, never silently re-enabled", async () => { + // MCP.connect persists `enabled: true` into whichever config owns the entry, + // so retrying a DISABLED entry would undo a deliberate global disable for + // every other project. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [{ datamate: { status: "disabled" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.connects).toHaveLength(0) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("a genuinely FAILED entry is still retried once", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) + expect(h.connects).toEqual(["datamate"]) + }) + + test("two overlapping SESSIONS in one project never attach concurrently", async () => { + // MCP state is instance-wide and MCP.add is last-writer-wins, while + // SessionRunState keeps independent runners per session id — so per-session + // ordering is not enough. The invariant is that no two attaches for the same + // project are ever in their mutating phase at the same time. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let inFlight = 0 + let peak = 0 + syncInternals.mcp!.add = async (name, cfg) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise((r) => setTimeout(r, 40)) + h.added.push({ name, cfg }) + inFlight -= 1 + } + + await Promise.all([ensure("sessionA"), ensure("sessionB")]) + + expect(h.added).toHaveLength(2) + expect(peak).toBe(1) // 2 without project-scoped serialization + }) +}) From a719eb51b35d19b6236a39d36abcbfd71781a753 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 02:42:07 +0800 Subject: [PATCH 07/67] fix(workspace): stop inferring user intent and ownership from MCP state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 5 on a84e7c393. Three findings, all verified; each new test was confirmed to fail with its fix reverted. **A removed entry is no longer mistaken for a user disable.** `MCP.remove` deletes the runtime status, and `MCP.status()` reports any *configured* entry with no status as `disabled`. So every rejection teardown made the following turn look like an explicit user disable, and the session returned `entry-disabled` for good — silently undoing the repairable-retry fix from the previous round for its most likely path: reject an unattributable engine, fail to replace it, install the engine, and never recover. Intent is now read from the config's actual `enabled: false`, which is the only place a user expresses it; the synthesized status is treated as the absence of information it is. **An unbound project no longer tears down an entry it cannot prove it owns.** Ownership was inferred from argv shape, but argv carries no provenance: a hand-authored `datamate start-stdio --datamate ` is byte-identical to what this feature writes, so the teardown took the user's own server offline on every first prompt — the opposite of the guarantee its comment claimed. This module's thesis is that you do not act on what you cannot attribute, and that has to apply to the module itself, so it now reports and leaves the entry alone. Doing better needs an explicit ownership marker written at persist time; that is a separate change and is recorded in the PR body rather than guessed at here. **The session and attach-chain maps are bounded.** They are module-level and a long-running `serve` process creates sessions indefinitely, so they grew for the life of the process with only a test-only reset to clear them. Sessions are now capped with oldest-first eviction; an evicted session simply re-attaches on its next turn, which is correct if not free. Storing this in `InstanceState` would be the thorough fix and is noted for later. 4 further tests: a removed entry recovering through install on a later turn, a genuinely disabled entry still respected, a pinned entry left alone in an unbound project, and the session map staying within its cap. The round-2 test that asserted the unbound teardown is reversed deliberately, and the round-4 disabled test now sets `enabled: false` rather than relying on the synthesized status. --- .../src/altimate/workspace/engine-sync.ts | 66 ++++++++++++----- .../altimate/workspace/engine-sync.test.ts | 73 ++++++++++++++++++- 2 files changed, 115 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 73e33033ab..30edeaeac7 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -105,7 +105,7 @@ export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolea * `command: string[]` argv, or the `{ command, args }` split an IDE writes and * `datamate-transport` normalises. Read defensively — this is merged config * written by other clients. */ -export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[] } +export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } @@ -322,14 +322,6 @@ export function pinnedWorkspace(entry: ExistingEntry | null): string | null { return null } -/** Did WE write this entry? Only an entry matching the exact command we persist - * is ours to tear down; an IDE-written or hand-edited entry belongs to the user - * and is left alone even when it is the wrong one for this project. */ -function isManagedEntry(entry: ExistingEntry | null): boolean { - const argv = commandArgv(entry) - return argv.length === 4 && argv[0] === ENGINE_BINARY && argv[1] === "start-stdio" && argv[2] === PIN_FLAG && !!argv[3] -} - /** Short, printable identity of an entry, for saying what was replaced. */ function describeEntry(entry: ExistingEntry | null): string { if (isUrlEntry(entry)) return entry.url @@ -388,18 +380,23 @@ async function run(): Promise { const binding = await resolveBinding() if (!binding) { - // An entry WE persisted for a binding that no longer exists is still started - // by MCP bootstrap on every launch, and would serve the OLD workspace's tools - // in a project that is no longer linked to it. Detach it — runtime only, the - // config entry is left in place — so an unlinked project does not silently - // keep another workspace's tools. Only our own managed entry qualifies. + // An entry left over from a binding that no longer exists still gets started + // by MCP bootstrap and can serve the OLD workspace's tools here. Tempting to + // tear it down — but we cannot prove we wrote it. argv shape is not + // provenance: a hand-authored `datamate start-stdio --datamate ` is + // byte-identical to ours, and removing it would take the user's own server + // offline on every first prompt. This module's whole thesis is that you do + // not act on something you cannot attribute, so it applies to itself here: + // report it and leave it alone. Attributing this properly needs an explicit + // ownership marker written at persist time, which is a separate change. const present = (await client.status())[DATAMATE_KEY] if (present) { const stale = await existingEntry(DATAMATE_KEY) - if (isManagedEntry(stale)) { - log.info("detaching a managed engine entry in an unbound project", { entry: describeEntry(stale) }) - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not detach the managed engine entry", { err: String(err) }) + const pin = pinnedWorkspace(stale) + if (pin) { + log.info("unbound project has an engine entry pinned to a workspace; leaving it alone", { + pinnedTo: pin, + entry: describeEntry(stale), }) } } @@ -447,7 +444,11 @@ async function run(): Promise { let connected = existing.status === "connected" if (!connected) { - if (existing.status === "disabled") { + // `MCP.status()` synthesizes "disabled" for any CONFIGURED entry that has + // no runtime status, and `MCP.remove` deletes the status — so every + // rejection teardown makes the next turn look like a user disable. Read the + // config's actual flag instead; only that is user intent. + if (existing.status === "disabled" && entry?.enabled === false) { // The user turned this entry off deliberately. Do NOT call `MCP.connect` // to "retry" it: that persists `enabled: true` into whichever config // owns the entry, so for a global `datamate` the first prompt in any @@ -679,8 +680,33 @@ function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) } +/** Cap on remembered sessions. + * + * These maps are module-level and a long-running `serve` process creates + * sessions indefinitely, so without a bound they grow for the life of the + * process. Evicting the oldest is safe: a session whose memo is dropped simply + * re-attaches on its next turn, which is correct, just not free. */ +export const MAX_TRACKED_SESSIONS = 256 + const sessions = new Map() +/** Insertion-ordered eviction — `Map` preserves insertion order, so the first + * key is the least recently STARTED attach. */ +function rememberSession(sessionID: string, entry: SessionAttach): void { + sessions.delete(sessionID) + sessions.set(sessionID, entry) + while (sessions.size > MAX_TRACKED_SESSIONS) { + const oldest = sessions.keys().next() + if (oldest.done) break + sessions.delete(oldest.value) + } +} + +/** Test seam — how many sessions are currently remembered. */ +export function trackedSessionsForTests(): number { + return sessions.size +} + /** What a memoised attach is valid FOR. * * Memoising on the session id alone was wrong: the binding can change while a @@ -737,7 +763,7 @@ export function ensure(sessionID: string): Promise { }, () => {}, ) - sessions.set(sessionID, entry) + rememberSession(sessionID, entry) return entry.task } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 13ed5e9b8d..ba4034e573 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -15,6 +15,8 @@ import { ATTACH_WAIT_MS, INSTALL_HINT, MIN_ENGINE_VERSION, + MAX_TRACKED_SESSIONS, + trackedSessionsForTests, type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -45,7 +47,7 @@ function install(opts: { declared?: { keys: string[]; extensionKeys: string[] } | null statuses?: Harness["statusQueue"] tools?: Record - existing?: { type?: string; url?: string; command?: string[] | string; args?: string[] } | null + existing?: { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } | null }): Harness { const h: Harness = { added: [], @@ -644,14 +646,16 @@ describe("ensure — reuse reports the declared-vs-delivered gap (rule 5)", () = }) describe("ensure — an unbound project does not keep a stale MANAGED entry", () => { - test("our own pinned entry is detached when the binding is gone", async () => { + test("a pinned entry is LEFT ALONE when the binding is gone — argv is not provenance", async () => { + // Reversed deliberately in round 5: a hand-authored entry is byte-identical + // to one we wrote, so tearing it down would take the user's server offline. const h = install({ binding: null, existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, statuses: [{ datamate: { status: "connected" } }], }) expect(await ensure("s1")).toEqual({ kind: "unbound" }) - expect(h.removes).toEqual(["datamate"]) + expect(h.removes).toHaveLength(0) }) test("an IDE-written entry is LEFT ALONE — it is the user's, not ours", async () => { @@ -807,7 +811,9 @@ describe("ensure — round 4", () => { // so retrying a DISABLED entry would undo a deliberate global disable for // every other project. const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, + // A real user disable is `enabled: false` in the config. The runtime + // status alone is not evidence of intent — see the round-5 test below. + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, statuses: [{ datamate: { status: "disabled" } }], }) expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) @@ -853,3 +859,62 @@ describe("ensure — round 4", () => { expect(peak).toBe(1) // 2 without project-scoped serialization }) }) + +describe("ensure — round 5", () => { + test("a REMOVED entry is not mistaken for a user disable — repair still works", async () => { + // MCP.remove deletes s.status[name], and MCP.status() reports a configured + // entry with no status as "disabled". Reading that as user intent made every + // turn after a rejection teardown return entry-disabled, permanently — + // silently undoing the repairable-retry fix from the previous round. + let onPath: string | null = null + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "disabled" } }, // synthesized after remove + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.which = () => onPath + + // Turn 1: rejected and torn down, and no engine to replace it with. + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.removes).toEqual(["datamate"]) + + // The user installs the engine and takes another turn. + onPath = "/usr/local/bin/datamate" + const second = await ensure("s1") + expect(second).not.toEqual({ kind: "entry-disabled" }) + expect(second).toMatchObject({ kind: "attached" }) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("a genuinely disabled entry (enabled:false in config) is still respected", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, + statuses: [{ datamate: { status: "disabled" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.connects).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("an unbound project does NOT tear down an entry it cannot prove it owns", async () => { + // argv shape is not provenance: a hand-authored entry looks identical to ours. + const h = install({ + binding: null, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) // the user's server stays up + }) + + test("the session map does not grow without bound", async () => { + install({ binding: null }) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 25; i++) await ensure(`s${i}`) + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + }) +}) From 18ada3e1bcbb8617b274d31f248f97e0c9b1dc66 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 03:46:40 +0800 Subject: [PATCH 08/67] fix(workspace): revalidate the binding before mutating MCP state Codex round 6. run() snapshots the binding at the top, but reaching a mutation costs seconds: a status call, one or two process spawns for --version, and the workspace allowlist over the network. A re-link inside that window left the attach acting for a workspace the project had already left. Per-project serialization did not help, and made one case worse. It orders the writes, so a stale attach installs FIRST and the replacement queues behind it; the waiting session's bounded first-turn wait is spent in that queue, so its tool list could be resolved while the stale workspace's engine was the one attached. Everything that mutates MCP state now revalidates the binding immediately before doing so and abandons instead: the spawn returns `superseded` without persisting or adding, and a teardown decided for a workspace we have left is skipped rather than executed. The check is a local cache read, cheap enough to repeat before each mutation. `superseded` is repairable, so the next turn attaches the workspace the project actually holds. Residual, deliberately not addressed here: a superseded attach still occupies the project queue until it reaches its guard, so a session waiting behind it can still spend part of its wait on work that will be discarded. Cancelling in-flight attaches rather than letting them reach the guard is a larger change. 2 tests: a re-link during the slow phase abandons without installing the old workspace, and an unchanged binding still attaches. The first fails with the guard reverted. --- .../src/altimate/workspace/engine-sync.ts | 36 ++++++++++++++++++- .../altimate/workspace/engine-sync.test.ts | 33 +++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 30edeaeac7..e0600bdd93 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -98,6 +98,7 @@ export type Outcome = | { kind: "engine-too-old"; found: string } | { kind: "connect-failed"; error: string } | { kind: "entry-disabled" } + | { kind: "superseded" } export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } @@ -419,6 +420,23 @@ async function run(): Promise { let replaced: string | undefined let replacedNote = "" + /** Is this attach still the one this project wants? + * + * The binding is snapshotted at the top of `run()`, but reaching a mutation + * costs seconds — a status call, one or two process spawns for `--version`, + * and the workspace allowlist over the network. A re-link inside that window + * leaves this attach acting for a workspace the project has already left, and + * per-project serialization does not help: it orders the writes, so the stale + * attach simply installs FIRST and the replacement queues behind it. Anything + * that mutates MCP state re-checks here and abandons instead. + * + * Cheap enough to call before every mutation — the binding is a local cache + * read, not a network one. */ + const stillCurrent = async (): Promise => { + const now = await resolveBinding().catch(() => null) + return !!now && String(now.datamateId) === workspaceId + } + /** Stop serving an entry we have judged untrustworthy for this workspace. * * Runtime-only (`MCP.remove`): closes the client and drops it from the tool @@ -433,6 +451,10 @@ async function run(): Promise { * tools we just decided it must not have. It also closes the client `MCP.add` * would otherwise overwrite without closing, which orphans a second engine. */ const detachRejected = async (why: Record): Promise => { + if (!(await stillCurrent())) { + log.info("skipping teardown; the binding changed while this attach was deciding", { workspaceId, ...why }) + return + } await client.remove(DATAMATE_KEY).catch((err) => { log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) }) @@ -620,6 +642,12 @@ async function run(): Promise { command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], enabled: true, } + if (!(await stillCurrent())) { + // Re-linked while we were probing. Installing now would attach the workspace + // this session has already left, and would win by arriving first. + log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) + return { kind: "superseded" } + } await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) @@ -674,7 +702,13 @@ type SessionAttach = { key?: string; task: Promise; waitTimedOut?: bool * it, fix a broken entry. Caching these for the life of the session means the * hint we just printed ("install it with …") can be followed and nothing * happens until a new session — so they are re-probed on the next turn. */ -const REPAIRABLE = new Set(["engine-missing", "engine-too-old", "connect-failed", "entry-disabled"]) +const REPAIRABLE = new Set([ + "engine-missing", + "engine-too-old", + "connect-failed", + "entry-disabled", + "superseded", +]) function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index ba4034e573..80f6cf94d8 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -918,3 +918,36 @@ describe("ensure — round 5", () => { expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) }) }) + +describe("ensure — round 6: a stale binding must not be installed", () => { + test("a re-link DURING an attach abandons it instead of installing the old workspace", async () => { + // run() snapshots the binding, then spends seconds in status, version and + // API work before persisting. A re-link inside that window used to install + // the workspace the session had already left. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + // The re-link lands while the attach is in its slow phase. + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } + } + + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + // The decisive assertion: workspace 42's engine is never installed. + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("an unchanged binding still attaches normally", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) +}) From 1a5d85ae4442f6fd5b37d7cde2a8c49caf48ebf1 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 03:58:19 +0800 Subject: [PATCH 09/67] fix(workspace): arm the retry flag synchronously, and report unexpected failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 7. Two findings, both verified; each new test was confirmed to fail with only its own fix reverted. **A repair retry is marked non-blocking before the entry is published.** The session entry is registered synchronously, because whenAttached reads it on the very next line — but the flag that makes a retry non-blocking was set after an await, so the timer was already armed by the time it ran. A retry that hung then charged the turn the full 15 seconds, which is exactly what that flag exists to prevent. Whether a turn is a repair retry depends only on the previous outcome, which is known synchronously; the workspace comparison is what needs the await. Deliberately conservative: if the binding also changed, the fresh attach may lose its wait for one turn. Failing to wait costs that turn's tools and is repaired by tools/list_changed; waiting wrongly costs every turn the full cap. The existing test for this passed for the wrong reason. Its retry settled immediately, so whenAttached returned on settle whatever the flag said. It now hangs the retry, which is the only way the flag is under test, and it fails against the old ordering. **An unexpected attach error now tells the user.** Every explicit failure branch emits guidance; a throw from outside them — an unwritable or malformed project config reaching persist — was converted to connect-failed and only logged. The caller discards the outcome and whenAttached returns void, so that single path left the user with neither tools nor an explanation. It now notifies before returning, which is what the rest of the module already promises. --- .../src/altimate/workspace/engine-sync.ts | 34 ++++++++++++++++--- .../altimate/workspace/engine-sync.test.ts | 32 ++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e0600bdd93..1bb03f992a 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -763,7 +763,22 @@ export function ensure(sessionID: string): Promise { // turn would sail past without waiting — which is exactly the first-turn gap // this module exists to close. All the async work happens inside the task. const previous = sessions.get(sessionID) - const entry = { key: previous?.key, waitTimedOut: previous?.waitTimedOut } as SessionAttach + // Decided SYNCHRONOUSLY, because the entry is published synchronously and + // `whenAttached` reads it on the very next line. Whether this is a repair + // retry depends only on the previous outcome, which is already known — the + // workspace comparison needs an await, and refining the flag after that await + // is too late: the timer is armed by then, so a hung retry charged the turn + // the full cap despite the retry being documented as non-blocking. + // + // Conservative in the right direction: if the binding also changed, the branch + // below resets this to false and that fresh attach may lose its wait for one + // turn. Failing to wait costs a turn's tools, which `tools/list_changed` + // repairs; waiting wrongly costs every turn 15 seconds. + const repairRetry = !!previous && isRepairable(previous.outcome) + const entry = { + key: previous?.key, + waitTimedOut: previous?.waitTimedOut || repairRetry, + } as SessionAttach entry.task = (async (): Promise => { const key = await attachKey() const sameWorkspace = !!previous && previous.key === key @@ -839,9 +854,20 @@ function serializeAttach(fn: () => Promise): Promise { * outcome logged exactly once. */ function attachOnce(sessionID: string): Promise { return serializeAttach(() => run()) - .catch((err): Outcome => { - log.warn("workspace engine attach failed", { err: String(err) }) - return { kind: "connect-failed", error: String(err) } + .catch(async (err): Promise => { + const error = String(err) + // Every explicit failure branch tells the user what is unavailable and + // why. An unexpected throw — an unwritable project config, a malformed + // one — must not be the single path that leaves them with neither tools + // nor an explanation, since the caller discards this outcome and + // `whenAttached` returns void. + log.warn("workspace engine attach failed", { err: error }) + await notify({ + title: "Workspace engine attach failed", + message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, + variant: "error", + }) + return { kind: "connect-failed", error } }) .then((outcome) => { // One line per session, whatever happened — silence is the defect this diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 80f6cf94d8..a995858f43 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -721,15 +721,21 @@ describe("ensure — a repairable failure is re-probed on the next turn", () => expect(h.added).toHaveLength(1) }) - test("a repairable retry does not re-arm the turn wait", async () => { - install({ which: null }) + test("a repairable retry does not re-arm the turn wait, even when the retry HANGS", async () => { + // The earlier version of this test let the retry settle immediately, so + // whenAttached returned on settle and the test passed whatever the flag + // said. The retry must hang for the flag to be the thing under test. + let onPath: string | null = null + install({}) + syncInternals.which = () => onPath expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) - // Next turn re-probes, but whenAttached must return immediately rather than - // charging this turn the full cap. + + onPath = "/usr/local/bin/datamate" + syncInternals.versionOf = () => new Promise(() => {}) // never settles void ensure("s1") const started = performance.now() await whenAttached("s1", 5_000) - expect(performance.now() - started).toBeLessThan(100) + expect(performance.now() - started).toBeLessThan(150) }) }) @@ -951,3 +957,19 @@ describe("ensure — round 6: a stale binding must not be installed", () => { expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) }) }) + +describe("ensure — round 7", () => { + test("an unexpected attach error still tells the user", async () => { + // Every explicit failure branch notifies; an unexpected throw must not be + // the one path that leaves the user with neither tools nor an explanation. + const h = install({ statuses: [{}] }) + syncInternals.persist = async () => { + throw new Error("EACCES: project config is not writable") + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("error") + expect(h.toasts[0].message).toContain("EACCES") + }) +}) From 92950861c9259352afb7260f33f14024bc41ff7c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:28:49 +0800 Subject: [PATCH 10/67] fix(workspace): close the handshake window, re-probe cached attaches, tighten version parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 8. Four findings, all verified; each fix confirmed to fail only its own tests when reverted. **The awaited engine add is itself an unchecked window.** The pre-mutation guard added last round runs before client.add, but that call waits for the MCP handshake and can run to the connection timeout. A re-link inside it left us having just installed the workspace the session had left — and because attaches are serialized, we installed it FIRST, so the replacement queued behind us while the waiting turn's budget drained. The binding is now revalidated after the add completes, and a superseded client is removed rather than left serving. **A cached success is only true while it stays true.** When an engine's child exits, MCP drops the client and marks the entry failed, but a settled successful outcome was returned before run() ever read that status. Every later turn then resolved without integration tools and nothing reconnected until a new session or a re-link. A memoised attached or reused outcome is now re-probed against the live MCP status, failing open so a status read that throws cannot invalidate a good attach. **Settled project attach chains are dropped.** Bounding the session map last round did not cover attachChains, which had no deletion or cap, so every project path a long-running server opens was retained for the life of the process. The entry is now removed once it settles, unless another attach has queued behind it. **Malformed version cores are refused.** parseInt reads "7rc" as 7, so "0.7rc.0" compared equal to a 0.7.0 floor, and a bare "1" won on major before its missing components were examined — unreadable output authorising reuse of an engine whose pin-locking is not established, contrary to this function's fail-closed intent. An exact three-component numeric core is now required, and anything else ranks below a readable one. 5 tests covering each, including the malformed-core matrix and a re-link landing inside the handshake. --- .../src/altimate/workspace/engine-sync.ts | 84 ++++++++++++++++--- .../altimate/workspace/engine-sync.test.ts | 64 ++++++++++++++ 2 files changed, 137 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 1bb03f992a..c6e6cc5ae8 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -151,22 +151,35 @@ export function isEnabled(): boolean { * of that version predates it. Treating them as equal let a beta clear the floor * and be trusted for reuse. */ export function compareVersions(a: string, b: string): number { + /** An exact `major.minor.patch` of digits, or null. + * + * `Number.parseInt` was too permissive: it reads "7rc" as 7, so "0.7rc.0" + * compared EQUAL to a 0.7.0 floor, and a bare "1" won on major before its + * missing components were ever examined. Unreadable output must never + * authorise reuse of an engine whose pin-locking cannot be established, so + * anything not exactly three numeric parts is treated as older. */ + const parseCore = (raw: string): number[] | null => { + const parts = raw.split(".") + if (parts.length !== 3) return null + if (!parts.every((part) => /^\d+$/.test(part))) return null + return parts.map((part) => Number(part)) + } const split = (v: string) => { const bare = v.trim().replace(/^v/, "") const plus = bare.indexOf("+") const noBuild = plus >= 0 ? bare.slice(0, plus) : bare const dash = noBuild.indexOf("-") return { - core: (dash >= 0 ? noBuild.slice(0, dash) : noBuild).split(".").map((n) => Number.parseInt(n, 10)), + core: parseCore(dash >= 0 ? noBuild.slice(0, dash) : noBuild), pre: dash >= 0 ? noBuild.slice(dash + 1) : "", } } const pa = split(a) const pb = split(b) + // A core we cannot read ranks below one we can, and two unreadable ones tie. + if (!pa.core || !pb.core) return !pa.core && !pb.core ? 0 : pa.core ? 1 : -1 for (let i = 0; i < 3; i++) { - const x = Number.isFinite(pa.core[i]) ? pa.core[i] : -1 - const y = Number.isFinite(pb.core[i]) ? pb.core[i] : -1 - if (x !== y) return x - y + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] } // Same core: a release outranks every pre-release of it (SemVer §11.3). if (!pa.pre && !pb.pre) return 0 @@ -651,6 +664,19 @@ async function run(): Promise { await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) + // `client.add` waits for the MCP handshake, which can run to the connection + // timeout — an unchecked window the pre-mutation guard cannot cover. A re-link + // inside it leaves us having just installed the workspace this session left, + // and serialization means we installed it FIRST, so the replacement queues + // behind us while the waiting turn's budget drains. Undo it rather than return. + if (!(await stillCurrent())) { + log.info("binding changed during the engine handshake; removing what we just installed", { workspaceId }) + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not remove the superseded engine", { err: String(err) }) + }) + return { kind: "superseded" } + } + // Rule 4 — a failed local engine is reported, never routed around. const after = (await client.status())[DATAMATE_KEY] if (after?.status !== "connected") { @@ -714,6 +740,29 @@ function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) } +/** Did this outcome leave an engine serving this session? */ +function wasServing(outcome: Outcome | undefined): boolean { + return outcome?.kind === "attached" || outcome?.kind === "reused" +} + +/** Is the engine we attached still connected? + * + * A memoised success is only true while it stays true. When the engine's child + * exits, MCP drops the client and marks the entry `failed`, but a settled + * successful outcome was returned before `run()` ever read that status — so + * every later turn resolved without the integration tools and nothing + * reconnected until a new session or a re-link. + * + * Fails OPEN: a status read that throws must not invalidate a good attach. */ +async function engineStillConnected(): Promise { + try { + return (await mcp().status())[DATAMATE_KEY]?.status === "connected" + } catch (err) { + log.warn("could not re-probe the engine connection; keeping the cached attach", { err: String(err) }) + return true + } +} + /** Cap on remembered sessions. * * These maps are module-level and a long-running `serve` process creates @@ -785,7 +834,11 @@ export function ensure(sessionID: string): Promise { // Same workspace and the attach either succeeded or is still in flight: // reuse it. A settled FAILURE is not reused — the user may have acted on // the hint it produced. - if (sameWorkspace && !isRepairable(previous!.outcome)) return previous!.task + if (sameWorkspace && !isRepairable(previous!.outcome)) { + // Re-probe before trusting a cached success — see `engineStillConnected`. + if (!wasServing(previous!.outcome) || (await engineStillConnected())) return previous!.task + log.info("cached attach is no longer connected; re-attaching", { sessionID }) + } entry.key = key if (sameWorkspace) { // Re-probing a repairable failure. Do NOT re-arm the wait: this runs on @@ -840,16 +893,25 @@ function serializeAttach(fn: () => Promise): Promise { // Run regardless of how the previous attach ended — a failure must not wedge // the chain for the rest of the process. const next = previous.then(fn, fn) - attachChains.set( - key, - next.then( - () => {}, - () => {}, - ), + const tail = next.then( + () => {}, + () => {}, ) + attachChains.set(key, tail) + // Drop the entry once it settles, unless another attach has already queued + // behind it — otherwise every project path a long-running server opens is + // retained for the life of the process. Bounding `sessions` did not cover this. + void tail.then(() => { + if (attachChains.get(key) === tail) attachChains.delete(key) + }) return next } +/** Test seam — how many project attach chains are currently retained. */ +export function trackedChainsForTests(): number { + return attachChains.size +} + /** One attach, serialized against every other attach in this project, with the * outcome logged exactly once. */ function attachOnce(sessionID: string): Promise { diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index a995858f43..0b52f76383 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -17,6 +17,7 @@ import { MIN_ENGINE_VERSION, MAX_TRACKED_SESSIONS, trackedSessionsForTests, + trackedChainsForTests, type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -973,3 +974,66 @@ describe("ensure — round 7", () => { expect(h.toasts[0].message).toContain("EACCES") }) }) + +describe("ensure — round 8", () => { + test("a malformed core is refused, not treated as equal to the floor", () => { + // parseInt("7rc") is 7, so "0.7rc.0" compared EQUAL to a 0.7.0 floor, and a + // bare "1" won on major before its missing components were examined. + expect(compareVersions("0.7rc.0", MIN_ENGINE_VERSION)).toBeLessThan(0) + expect(compareVersions("1", MIN_ENGINE_VERSION)).toBeLessThan(0) + expect(compareVersions("1.0", MIN_ENGINE_VERSION)).toBeLessThan(0) + // Well-formed versions must still behave. + expect(compareVersions("0.7.0", MIN_ENGINE_VERSION)).toBe(0) + expect(compareVersions("1.0.0", MIN_ENGINE_VERSION)).toBeGreaterThan(0) + expect(compareVersions("0.6.9", MIN_ENGINE_VERSION)).toBeLessThan(0) + }) + + test("an engine reporting a malformed version is refused", async () => { + const h = install({ version: "0.7rc.0" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.7rc.0" }) + expect(h.added).toHaveLength(0) + }) + + test("a re-link DURING the engine add is caught after it completes", async () => { + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + // The re-link lands while the MCP handshake is in flight. + syncInternals.mcp!.add = async (name, cfg) => { + h.added.push({ name, cfg }) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + // The client we installed for the workspace we left must not stay serving. + expect(h.removes).toEqual(["datamate"]) + }) + + test("a cached SUCCESS is re-probed: a died engine re-attaches", async () => { + const h = install({ + statuses: [ + {}, + { datamate: { status: "connected" } }, + { datamate: { status: "failed", error: "Connection closed" } }, + {}, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1 }, + }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + // The engine's child exits; MCP marks it failed. The next turn must notice. + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(h.added).toHaveLength(2) + }) + + test("settled project attach chains are not retained", async () => { + install({ binding: null }) + await ensure("s1") + expect(trackedChainsForTests()).toBe(0) + }) +}) From 791a2863bf1b48cef26032640f1122b53f7f515d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:32:30 +0800 Subject: [PATCH 11/67] feat(workspace): expose a read-only settledOutcome for other modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace precedence needs to attest which engine is actually serving a session, and had been awaiting ensure() to get it. That is unsafe in two ways: ensure() builds a fresh task per call and awaits the binding before resolving, so it never returns an already-settled promise and re-registers the session entry once per turn — mutating attach bookkeeping a caller only meant to read — and awaiting it is unbounded, reintroducing the prompt hang the bounded whenAttached exists to prevent. settledOutcome(sessionID) is a pure read of state already held: no task, no registration, no await. It returns undefined while an attach is in flight and for a session that never attached, so callers must read undefined as "not known yet" rather than "no engine". 3 tests: undefined before and settled after, undefined while in flight, and repeated reads leaving the session memo, the project chain and the attach count untouched. --- .../src/altimate/workspace/engine-sync.ts | 17 +++++++++ .../altimate/workspace/engine-sync.test.ts | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index c6e6cc5ae8..dc8c56660b 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -939,6 +939,23 @@ function attachOnce(sessionID: string): Promise { }) } +/** The memoised outcome for a session, if its attach has already settled. + * + * A pure read: it creates no task, registers nothing, awaits nothing, and does + * not touch the memo or the project chain. `ensure()` is deliberately unsuitable + * for this — it builds a fresh task per call and awaits the binding before + * resolving, so a caller polling it would never see an already-settled promise + * AND would re-register the session entry once per turn, mutating bookkeeping it + * only meant to read. Worse, awaiting it is unbounded, which reintroduces the + * prompt hang the bounded `whenAttached` exists to prevent. + * + * Returns undefined while an attach is still in flight, and for a session that + * has never attached. Callers must treat undefined as "not known yet", never as + * "no engine". */ +export function settledOutcome(sessionID: string): Outcome | undefined { + return sessions.get(sessionID)?.outcome +} + /** Wait for a session's in-flight attach, capped. * * A turn resolves its tool list up front, before the per-turn block that starts diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 0b52f76383..a59996c54d 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -18,6 +18,7 @@ import { MAX_TRACKED_SESSIONS, trackedSessionsForTests, trackedChainsForTests, + settledOutcome, type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -1037,3 +1038,39 @@ describe("ensure — round 8", () => { expect(trackedChainsForTests()).toBe(0) }) }) + +describe("settledOutcome — a read-only view for other modules", () => { + test("undefined before an attach exists, the outcome after it settles", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect(settledOutcome("s1")).toBeUndefined() // never attached + const outcome = await ensure("s1") + expect(settledOutcome("s1")).toEqual(outcome) + expect(h.added).toHaveLength(1) + }) + + test("undefined while the attach is still in flight — never a premature answer", async () => { + install({}) + syncInternals.versionOf = () => new Promise(() => {}) // never settles + void ensure("s1") + expect(settledOutcome("s1")).toBeUndefined() + await new Promise((r) => setTimeout(r, 20)) + expect(settledOutcome("s1")).toBeUndefined() + }) + + test("reading never mutates the memo or the project chain", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const sessionsBefore = trackedSessionsForTests() + const chainsBefore = trackedChainsForTests() + for (let i = 0; i < 5; i++) settledOutcome("s1") + expect(trackedSessionsForTests()).toBe(sessionsBefore) + expect(trackedChainsForTests()).toBe(chainsBefore) + expect(h.added).toHaveLength(1) // no attach was triggered by reading + }) +}) From d6f5b5be54fa2bcbd6649de01d41d4e61dbac235 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 04:49:01 +0800 Subject: [PATCH 12/67] fix(workspace): honour a live disconnect, and say when an engine is unrunnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 9, the final review round. Two findings, both verified; each fix confirmed to fail only its own test when reverted. **A live disconnect is honoured even when the config cache is stale.** The runtime status is authoritative for "not running"; the config is authoritative for "the user turned it off" — and the two can disagree. MCP.disconnect writes enabled:false straight to disk without invalidating Config, so immediately after a user disconnects, the cached entry still reports enabled:true. Reading the cache made the disabled status look synthesized, so the entry was reconnected and persisted enabled again, undoing the disconnect — globally, when the owning entry is global. The owning config is now re-read before that decision. This is the third distinct route by which a raw config write without cache invalidation has produced a wrong answer in this module, and the second time it has defeated a fix from an earlier round. **The integrations listing no longer reports an empty catalog when entries were hidden.** A catalog of nothing but extension-type integrations filters down to empty, and the explanatory footer sits after the early return, so the tool said no integrations were available at all. The count and the VS Code requirement are now included in that branch too. Also, authorised separately rather than found by review: an engine that cannot be run is now described as such instead of as out of date. versionOf reads stdout only and returns null when the process fails, so a null means the binary produced no version — broken, not old. Both states were previously reported as "too old", which sent more than one debugging session hunting a version mismatch that did not exist. 3 tests: a stale-cache disconnect stays disconnected and is never reconnected or persisted, and the two refusal messages differ for an unreadable version versus a genuinely old one. --- .../opencode/src/altimate/tools/datamate.ts | 12 +++- .../src/altimate/workspace/engine-sync.ts | 55 ++++++++++++++++--- .../altimate/workspace/engine-sync.test.ts | 39 +++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 8f0897dc78..d6d6d16992 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -147,10 +147,18 @@ async function handleListIntegrations() { const hidden = catalog.length - integrations.length // altimate_change end if (integrations.length === 0) { + // A catalog of nothing but extension-type entries filters down to empty, + // and the footer below never runs — so this branch used to report a + // genuinely empty catalog. Say what was hidden here too, or the model + // reports "no integrations" when the workspace in fact has several. + const omitted = + hidden > 0 + ? ` ${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they require a live VS Code bridge and are not available from the CLI.` + : "" return { - title: "Integrations: none found", + title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found", metadata: { count: 0, hidden }, - output: "No integrations available.", + output: `No integrations available.${omitted}`, } } const lines = ["ID | Name | Tools", "---|------|------"] diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index dc8c56660b..25a706ada6 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -132,6 +132,7 @@ export const syncInternals: { persist?: (name: string, cfg: LocalMcpConfig) => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise + refreshConfig?: () => Promise declared?: (datamateId: string) => Promise notify?: (toast: Toast) => Promise } = {} @@ -289,6 +290,18 @@ async function persist(name: string, cfg: LocalMcpConfig): Promise { }) } +/** Drop the per-instance config cache so the next read sees the file. + * + * `MCP.disconnect` writes `enabled: false` straight to disk and does not + * invalidate `Config`, so a cached entry can still report `enabled: true` right + * after a user has disconnected. */ +async function refreshConfig(): Promise { + if (syncInternals.refreshConfig) return syncInternals.refreshConfig() + await Config.invalidate().catch((err) => { + log.warn("could not refresh the config cache", { err: String(err) }) + }) +} + async function existingEntry(name: string): Promise { if (syncInternals.existingEntry) return syncInternals.existingEntry(name) try { @@ -380,6 +393,27 @@ async function notify(toast: Toast): Promise { // The attach flow // --------------------------------------------------------------------------- +/** Why an engine was refused, in the user's terms. + * + * "Too old" and "could not be run at all" are the same code path but very + * different problems, and conflating them sent more than one debugging session + * hunting a version mismatch that did not exist. `versionOf` reads stdout only + * and returns null when the process fails, so a null here means the binary did + * not produce a version — broken, not merely out of date. */ +function describeRefusal(found: string | null, workspaceName: string): string { + if (!found) { + return ( + `The ${ENGINE_BINARY} on PATH did not report a usable version, so it cannot be used for workspace ` + + `"${workspaceName}". It is more likely broken than out of date — try running \`${ENGINE_BINARY} --version\` ` + + `directly. Reinstall with: ${INSTALL_HINT}` + ) + } + return ( + `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + + `Update with: ${INSTALL_HINT}` + ) +} + function describeMissing(missing: string[]): string { if (missing.length === 0) return "" const shown = missing.slice(0, 5).join(", ") @@ -483,7 +517,16 @@ async function run(): Promise { // no runtime status, and `MCP.remove` deletes the status — so every // rejection teardown makes the next turn look like a user disable. Read the // config's actual flag instead; only that is user intent. - if (existing.status === "disabled" && entry?.enabled === false) { + // The runtime status is authoritative for "not running"; the CONFIG is + // authoritative for "the user turned it off" — and the two can disagree. + // `MCP.disconnect` writes `enabled: false` to disk WITHOUT invalidating + // Config, so a cached entry still says `enabled: true` immediately after a + // user disconnects. Treating that as a synthesized status would reconnect + // and persist it enabled again, undoing their disconnect — globally, if + // the owning entry is global. Read the file before deciding. + if (existing.status === "disabled") await refreshConfig() + const owning = existing.status === "disabled" ? await existingEntry(DATAMATE_KEY) : entry + if (existing.status === "disabled" && owning?.enabled === false) { // The user turned this entry off deliberately. Do NOT call `MCP.connect` // to "retry" it: that persists `enabled: true` into whichever config // owns the entry, so for a global `datamate` the first prompt in any @@ -601,10 +644,8 @@ async function run(): Promise { // return "too old" while still serving the too-old engine's tools. await detachRejected({ workspaceId, reason: "below-floor", found: label }) await notify({ - title: "Workspace engine is too old", - message: - `The engine serving workspace "${binding.datamateName}" reports ${label}; this client needs ` + - `${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), variant: "warning", }) return { kind: "engine-too-old", found: label } @@ -641,8 +682,8 @@ async function run(): Promise { if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { const label = found ?? "unknown" await notify({ - title: "Workspace engine is too old", - message: `Found ${ENGINE_BINARY} ${label}; this client needs ${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), variant: "warning", }) return { kind: "engine-too-old", found: label } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index a59996c54d..c234a56c04 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1074,3 +1074,42 @@ describe("settledOutcome — a read-only view for other modules", () => { expect(h.added).toHaveLength(1) // no attach was triggered by reading }) }) + +describe("ensure — round 9", () => { + test("a live disconnect is honoured even when the config cache is stale", async () => { + // MCP.disconnect writes enabled:false to disk without invalidating Config, + // so the cached entry still says enabled:true. Believing the cache would + // reconnect the entry and persist it enabled again — undoing the user's + // disconnect, globally if the owning entry is global. + let refreshed = false + const h = install({ + statuses: [{ datamate: { status: "disabled" } }], + }) + syncInternals.refreshConfig = async () => { + refreshed = true + } + syncInternals.existingEntry = async () => + refreshed + ? { type: "local", command: ["datamate", "start-stdio"], enabled: false } // on disk + : { type: "local", command: ["datamate", "start-stdio"], enabled: true } // stale cache + + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(refreshed).toBe(true) + expect(h.connects).toHaveLength(0) // MCP.connect would persist enabled:true + expect(h.persisted).toHaveLength(0) + }) + + test("an unrunnable engine is described as broken, not as out of date", async () => { + const broken = install({ version: null }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "unknown" }) + expect(broken.toasts[0].title).toContain("not runnable") + expect(broken.toasts[0].message).toContain("did not report a usable version") + expect(broken.toasts[0].message).not.toContain("needs 0.7.0 or newer") + + resetForTests() + const old = install({ version: "0.6.9" }) + expect(await ensure("s2")).toEqual({ kind: "engine-too-old", found: "0.6.9" }) + expect(old.toasts[0].title).toContain("too old") + expect(old.toasts[0].message).toContain("needs 0.7.0 or newer") + }) +}) From fee2a0ce41c47c90ce732700907e459c976ce97e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:06:50 +0800 Subject: [PATCH 13/67] fix(workspace): announce late attachments, re-attribute cached successes, close the stale-config class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 10. Three findings, all verified; each fix confirmed to fail only its own test when reverted. The config-cache class fix is folded in as agreed. **A late attachment now announces itself.** MCP.add stores the client but publishes nothing, so an attach landing after the turn's bounded wait — or on a repair retry, which never waits — produced tools the session had no way to learn about until the user sent another message. This branch has been documenting a tools-changed fallback as the justification for the wait being safe; that fallback depended on an event nobody published. It is published now. **A cached success is re-attributed, not merely re-connected.** Link A to B and back to A, with another session attaching B in between, and this session's key matches its original memo while the instance-wide client is serving B. The round-8 re-probe only checked that something was connected, so every later turn would expose B's tools under binding A. The live entry's pin is now checked too, which is what makes it ours. **The optional catalog lookup can no longer block a local spawn.** declared() is reporting only, but it runs before the engine is launched and its HTTP layer has no abort timeout, so an API that accepts a connection and then stalls stopped a good cached binding and an installed engine from ever attaching — and later turns kept returning the same pending task, since a pending attach has no settled repairable outcome. It is bounded now; reporting degrades, attaching does not wait on it. **Config reads are fresh by construction.** Three separate bugs in this module came from reading a per-instance cache after someone else wrote: our own addMcpToConfig, MCP.disconnect writing enabled:false, and an IDE rewriting the entry — which never goes through Config at all. Two of them defeated a fix from an earlier round. The writers cannot be enumerated, so freshness belongs at the point of read: freshConfig() is now the module's only path to config, and the single reader goes through it, which makes all three call sites correct without touching them. The ad-hoc invalidation added last round is removed rather than left as a second answer to the same question. The cost is named rather than hidden: invalidating drops the shared per-instance cache, so other Config consumers re-read. Also fixed in the test harness: the mocked config now reflects an entry after it is persisted, as production does. Without that the pin re-check saw no entry and a legitimate memo looked like a workspace change. --- .../src/altimate/workspace/engine-sync.ts | 95 +++++++++++++++---- .../altimate/workspace/engine-sync.test.ts | 94 ++++++++++++++++-- 2 files changed, 164 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 25a706ada6..a6e9f20fac 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -61,7 +61,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" -import { MCP } from "@/mcp" +import { MCP, ToolsChanged } from "@/mcp" import { addMcpToConfig, resolveConfigPath } from "@/mcp/config" import { Config } from "@/config/config" import { AltimateApi } from "@/altimate/api/client" @@ -73,6 +73,9 @@ import { readLocalBinding, type CachedBinding } from "./state" const log = Log.create({ service: "workspace-engine" }) +/** How long the optional allowlist lookup may delay a local spawn. */ +const DECLARED_TIMEOUT_MS = 4_000 + /** Oldest engine this client is known to work against. * * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings @@ -132,7 +135,8 @@ export const syncInternals: { persist?: (name: string, cfg: LocalMcpConfig) => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise - refreshConfig?: () => Promise + freshConfig?: () => Promise<{ mcp?: Record }> + toolsChanged?: () => Promise declared?: (datamateId: string) => Promise notify?: (toast: Toast) => Promise } = {} @@ -290,22 +294,30 @@ async function persist(name: string, cfg: LocalMcpConfig): Promise { }) } -/** Drop the per-instance config cache so the next read sees the file. +/** The module's ONLY path to config, and it is always fresh. + * + * `Config.get()` is cached per instance, and this module has now been bitten + * three times by reading it after someone else wrote: our own `addMcpToConfig`, + * `MCP.disconnect` writing `enabled: false`, and an IDE rewriting the entry — + * which never goes through `Config` at all. Two of those defeated a fix from an + * earlier round. * - * `MCP.disconnect` writes `enabled: false` straight to disk and does not - * invalidate `Config`, so a cached entry can still report `enabled: true` right - * after a user has disconnected. */ -async function refreshConfig(): Promise { - if (syncInternals.refreshConfig) return syncInternals.refreshConfig() + * Enumerating the writers is therefore not possible, so freshness is structural + * at the point of READ rather than remembered at each write site. The cost is + * real and shared: invalidating drops the per-instance cache for every other + * `Config` consumer too. That is the price of not having a fourth instance. */ +async function freshConfig(): Promise<{ mcp?: Record }> { + if (syncInternals.freshConfig) return syncInternals.freshConfig() await Config.invalidate().catch((err) => { log.warn("could not refresh the config cache", { err: String(err) }) }) + return (await Config.get()) as { mcp?: Record } } async function existingEntry(name: string): Promise { if (syncInternals.existingEntry) return syncInternals.existingEntry(name) try { - const cfg = (await Config.get()) as { mcp?: Record } + const cfg = await freshConfig() return cfg.mcp?.[name] ?? null } catch (err) { log.warn("could not read merged MCP config", { name, err: String(err) }) @@ -378,6 +390,24 @@ async function declared(datamateId: string): Promise { } } +/** Tell the session its tool list changed. + * + * `MCP.add` stores the client but publishes nothing, so an attach that lands + * after the turn's bounded wait — or on a repair retry, which never waits — + * produced tools the session had no way to learn about until the user sent + * another message. The documented fallback for exceeding the cap depends on + * this event existing, so it has to be published here. */ +async function announceToolsChanged(): Promise { + if (syncInternals.toolsChanged) return syncInternals.toolsChanged() + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(ToolsChanged, { server: DATAMATE_KEY })), + ) + } catch (err) { + log.warn("could not announce the workspace engine tool change", { err: String(err) }) + } +} + async function notify(toast: Toast): Promise { if (syncInternals.notify) return syncInternals.notify(toast) try { @@ -524,8 +554,8 @@ async function run(): Promise { // user disconnects. Treating that as a synthesized status would reconnect // and persist it enabled again, undoing their disconnect — globally, if // the owning entry is global. Read the file before deciding. - if (existing.status === "disabled") await refreshConfig() - const owning = existing.status === "disabled" ? await existingEntry(DATAMATE_KEY) : entry + // `existingEntry` is always fresh now, so `entry` already reflects disk. + const owning = entry if (existing.status === "disabled" && owning?.enabled === false) { // The user turned this entry off deliberately. Do NOT call `MCP.connect` // to "retry" it: that persists `enabled: true` into whichever config @@ -662,7 +692,23 @@ async function run(): Promise { } } - const declaredKeys = await declared(workspaceId) + // Bounded: this lookup is reporting only, but it runs BEFORE the engine is + // launched and its HTTP layer has no abort timeout — so an API that accepts a + // connection and then stalls stopped a good cached binding and an installed + // engine from ever attaching. Reporting degrades; attaching does not wait. + const declaredKeys = await Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + const timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; attaching without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) const declaredCount = declaredKeys?.keys.length ?? 0 // Rule 2 / 3 — opportunistic use, or an offer. Never an install. @@ -730,6 +776,10 @@ async function run(): Promise { return { kind: "connect-failed", error } } + // The add succeeded and is ours: announce it, so a turn that had already given + // up waiting still learns the tools arrived. + await announceToolsChanged() + // Rule 5 — report declared-but-missing. const present = engineToolKeys(await client.tools()) const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] @@ -795,11 +845,16 @@ function wasServing(outcome: Outcome | undefined): boolean { * reconnected until a new session or a re-link. * * Fails OPEN: a status read that throws must not invalidate a good attach. */ -async function engineStillConnected(): Promise { +async function engineStillOurs(workspaceId: string): Promise { try { - return (await mcp().status())[DATAMATE_KEY]?.status === "connected" + if ((await mcp().status())[DATAMATE_KEY]?.status !== "connected") return false + // Connected is not enough. Link A -> B -> A with another session attaching B + // in between, and this session's key matches its original memo while the + // instance-wide client is serving B — so the cached success would expose B's + // tools under binding A. The pin is what makes it ours. + return pinnedWorkspace(await existingEntry(DATAMATE_KEY)) === workspaceId } catch (err) { - log.warn("could not re-probe the engine connection; keeping the cached attach", { err: String(err) }) + log.warn("could not re-probe the engine attribution; keeping the cached attach", { err: String(err) }) return true } } @@ -840,6 +895,13 @@ export function trackedSessionsForTests(): number { * workspace would keep serving the old workspace's tools, both silently and for * the rest of the session. Keying on the bound workspace makes a re-link produce * a fresh attach on the next turn and leaves everything else memoised as before. */ +/** The bound workspace id, or null when unbound or disabled. */ +async function attachKeyWorkspace(): Promise { + if (!isEnabled()) return null + const binding = await resolveBinding() + return binding ? String(binding.datamateId) : null +} + async function attachKey(): Promise { if (!isEnabled()) return "disabled" const binding = await resolveBinding() @@ -877,7 +939,8 @@ export function ensure(sessionID: string): Promise { // the hint it produced. if (sameWorkspace && !isRepairable(previous!.outcome)) { // Re-probe before trusting a cached success — see `engineStillConnected`. - if (!wasServing(previous!.outcome) || (await engineStillConnected())) return previous!.task + const boundTo = await attachKeyWorkspace() + if (!wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo))) return previous!.task log.info("cached attach is no longer connected; re-attaching", { sessionID }) } entry.key = key diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index c234a56c04..1534949e66 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -22,6 +22,7 @@ import { type LocalMcpConfig, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE @@ -38,6 +39,7 @@ type Harness = { connects: string[] removes: string[] toasts: Array<{ title: string; message: string; variant: string }> + toolsChanged: number statusQueue: Array> tools: Record } @@ -57,6 +59,7 @@ function install(opts: { connects: [], removes: [], toasts: [], + toolsChanged: 0, statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, } @@ -71,10 +74,20 @@ function install(opts: { syncInternals.persist = async (name, cfg) => { h.persisted.push({ name, cfg }) } - syncInternals.existingEntry = async () => (opts.existing === undefined ? null : opts.existing) + syncInternals.existingEntry = async () => { + if (opts.existing !== undefined) return opts.existing + // Production persists the pinned entry before adding it, so a later read + // sees it. Without this the harness under-reports and a legitimate memo + // looks like a workspace change. + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } syncInternals.notify = async (toast) => { h.toasts.push(toast) } + syncInternals.toolsChanged = async () => { + h.toolsChanged += 1 + } syncInternals.mcp = { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => { @@ -1081,20 +1094,19 @@ describe("ensure — round 9", () => { // so the cached entry still says enabled:true. Believing the cache would // reconnect the entry and persist it enabled again — undoing the user's // disconnect, globally if the owning entry is global. - let refreshed = false + let reads = 0 const h = install({ statuses: [{ datamate: { status: "disabled" } }], }) - syncInternals.refreshConfig = async () => { - refreshed = true + // Reads go through freshConfig now, so the disk value is what is seen. + syncInternals.existingEntry = undefined + syncInternals.freshConfig = async () => { + reads += 1 + return { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } } - syncInternals.existingEntry = async () => - refreshed - ? { type: "local", command: ["datamate", "start-stdio"], enabled: false } // on disk - : { type: "local", command: ["datamate", "start-stdio"], enabled: true } // stale cache expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) - expect(refreshed).toBe(true) + expect(reads).toBeGreaterThan(0) expect(h.connects).toHaveLength(0) // MCP.connect would persist enabled:true expect(h.persisted).toHaveLength(0) }) @@ -1113,3 +1125,67 @@ describe("ensure — round 9", () => { expect(old.toasts[0].message).toContain("needs 0.7.0 or newer") }) }) + +describe("ensure — round 10", () => { + test("a successful add announces the new tools", async () => { + // MCP.add stores the client but publishes nothing, so a late attach — after + // the bounded wait expired, or on a repair retry — left the session with + // tools it had no way to learn about until another user turn. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.toolsChanged).toBe(1) + }) + + test("a cached success is re-attributed, not merely re-connected", async () => { + // A -> B -> A while another session attached B: the key matches this + // session's original memo and the client is connected, but it is serving B. + let pinned = "42" + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", pinned], + }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + + pinned = "99" // the live entry now serves another workspace + const second = await ensure("s1") + expect(second).not.toBe(first) + }) + + test("a stalled catalog lookup cannot block the local engine", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.declared = () => new Promise(() => {}) // API accepts then stalls + const outcome = await ensure("s1") + // The engine is on PATH and the binding is cached; reporting is optional. + expect(outcome).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("config is read fresh, so an external write is never missed", async () => { + // Nothing in this module can enumerate the writers — MCP writes raw, and an + // IDE rewriting the entry never touches Config at all — so freshness has to + // be structural at the point of read. + let onDisk: Record = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + let invalidations = 0 + const h = install({ statuses: [{ datamate: { status: "disabled" } }] }) + syncInternals.existingEntry = undefined // let the real reader go through freshConfig + syncInternals.freshConfig = async () => { + invalidations += 1 + return { mcp: { datamate: onDisk as ExistingEntry } } + } + onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: false } + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(invalidations).toBeGreaterThan(0) + expect(h.connects).toHaveLength(0) + }) +}) From 4e3e28a3807ffb27bba66ebe34378570f4818902 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:22:48 +0800 Subject: [PATCH 14/67] fix(workspace): bound the catalog lookup on both paths, and stop overstating the late-attach fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 11. Two findings, both verified. **The allowlist bound covered only one of two call sites.** Last round bounded the fresh-spawn path and left a reused engine awaiting the same lookup with no limit — a partial fix that read as a complete one. Both paths now go through a single bounded helper, so there is one answer rather than two. The underlying request was genuinely unbounded, not merely slow: the generic API request performed a bare fetch with no abort signal, while two other functions in that same client already attach one. It does now, so a stalled server releases its socket instead of accumulating pending fetches across repair retries — which a Promise.race alone cannot do, since racing a promise does not cancel what it is racing. **Publishing a tool-change event does not refresh the running turn, and this module said otherwise.** The invocation's tool set is passed to the model before a late attach completes and cannot be rebuilt mid-call; the session's subscriber only logs, and the next resolveTools is what picks the tools up. So exceeding the bounded wait costs a turn, not a session. That is a correction to a claim this branch has repeated since the wait was introduced. Publishing the event remains right — nothing downstream could otherwise observe a late attach at all — but it is traceability and a hook for subscribers that act between turns, not a live refresh, and the comment now says so rather than promising delivery it cannot make. 1 test: a stalled catalog lookup no longer blocks the reuse path, and reuse still succeeds with only the optional reporting degraded. It fails with the reuse-path bound reverted. --- packages/opencode/src/altimate/api/client.ts | 9 ++- .../src/altimate/workspace/engine-sync.ts | 56 ++++++++++++------- .../altimate/workspace/engine-sync.test.ts | 18 ++++++ 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 088981bef1..98eb048d5d 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -232,7 +232,14 @@ export namespace AltimateApi { async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { const url = `${creds.altimateUrl}${endpoint}` + // altimate_change start — upstream_fix: bound every API request. Without a + // signal a stalled server holds the caller indefinitely; the workspace attach + // awaited this on its critical path. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + // altimate_change end const res = await fetch(url, { + signal: controller.signal, method, headers: { "Content-Type": "application/json", @@ -240,7 +247,7 @@ export namespace AltimateApi { "x-tenant": creds.altimateInstanceName, }, ...(body ? { body: JSON.stringify(body) } : {}), - }) + }).finally(() => clearTimeout(timeout)) if (!res.ok) { throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) } diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index a6e9f20fac..8b6dd74bb1 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -392,11 +392,16 @@ async function declared(datamateId: string): Promise { /** Tell the session its tool list changed. * - * `MCP.add` stores the client but publishes nothing, so an attach that lands - * after the turn's bounded wait — or on a repair retry, which never waits — - * produced tools the session had no way to learn about until the user sent - * another message. The documented fallback for exceeding the cap depends on - * this event existing, so it has to be published here. */ + * `MCP.add` stores the client but publishes nothing, so nothing downstream could + * even observe a late attach. This restores that signal. + * + * What it does NOT do, stated plainly because this module claimed otherwise for + * several revisions: it does not give tools to the invocation already running. + * That turn's tool set was passed to the model before the attach finished and + * cannot be rebuilt mid-call — the session's subscriber only logs, and the next + * `resolveTools` is what picks the tools up. So exceeding the bounded wait costs + * a turn, not a session. The event is worth publishing for traceability and for + * any subscriber that can act between turns; it is not a live refresh. */ async function announceToolsChanged(): Promise { if (syncInternals.toolsChanged) return syncInternals.toolsChanged() try { @@ -408,6 +413,31 @@ async function announceToolsChanged(): Promise { } } +/** The workspace allowlist, bounded. + * + * Reporting only — the attach must never wait on it. The bound was previously + * applied to the spawn path alone, leaving a reused engine awaiting it with no + * limit. Both paths go through here now, so there is one answer rather than two. + * + * The underlying request is separately abortable (the API client attaches a + * signal), so a stalled server releases its socket instead of accumulating + * pending fetches across repair retries. */ +async function declaredBounded(workspaceId: string): Promise { + return Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + const timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) +} + async function notify(toast: Toast): Promise { if (syncInternals.notify) return syncInternals.notify(toast) try { @@ -638,7 +668,7 @@ async function run(): Promise { // attach used to say so. Reuse is the COMMON path, so staying silent // here is where the gap would actually go unnoticed. const present = engineToolKeys(await client.tools()) - const declaredKeys = await declared(workspaceId) + const declaredKeys = await declaredBounded(workspaceId) const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] const available = present.size if (declaredKeys && missing.length > 0) { @@ -696,19 +726,7 @@ async function run(): Promise { // launched and its HTTP layer has no abort timeout — so an API that accepts a // connection and then stalls stopped a good cached binding and an installed // engine from ever attaching. Reporting degrades; attaching does not wait. - const declaredKeys = await Promise.race([ - declared(workspaceId), - new Promise((resolve) => { - const timer = setTimeout(() => { - log.warn("workspace allowlist lookup timed out; attaching without the declared-vs-delivered report", { - workspaceId, - timeoutMs: DECLARED_TIMEOUT_MS, - }) - resolve(null) - }, DECLARED_TIMEOUT_MS) - timer.unref?.() - }), - ]) + const declaredKeys = await declaredBounded(workspaceId) const declaredCount = declaredKeys?.keys.length ?? 0 // Rule 2 / 3 — opportunistic use, or an offer. Never an install. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 1534949e66..fecb434513 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1189,3 +1189,21 @@ describe("ensure — round 10", () => { expect(h.connects).toHaveLength(0) }) }) + +describe("ensure — round 11", () => { + test("a stalled catalog lookup cannot block the REUSE path either", async () => { + // The bound added last round covered only the fresh-spawn path; a compatible + // pinned engine still awaited the lookup with no limit, and the generic API + // request attaches no abort signal at all. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.declared = () => new Promise(() => {}) // accepts, then stalls + const outcome = await ensure("s1") + // Reuse still succeeds; only the optional reporting degrades. + expect(outcome).toMatchObject({ kind: "reused", available: 1 }) + expect(h.added).toHaveLength(0) + }) +}) From d2f924ab08a6ba006cf7bd34bd039a7d310a4f03 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:35:16 +0800 Subject: [PATCH 15/67] fix(workspace): arm the abort through the body, read config before the status gate, cancel the losing timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 12. Three findings, all verified, all follow-ups on last round's fixes. **The abort now stays armed until the response body is read.** fetch resolves on headers, so clearing the timer there left a server that sends headers and then stalls mid-body hanging indefinitely, holding its socket. The bound added last round covered the wrong half of the request. Two sibling functions in the same client share this shape. They are pre-existing and untouched here rather than swept in silently; noted for a follow-up. **Config is read before the MCP status gate, not after.** MCP.status() reads the same cached config as everything else, so an entry added directly by an IDE or a user after the cache was warmed is absent from status. The entry check then never ran and the managed entry was persisted straight over the externally authored one. Reading the entry first is what refreshes that cache, so the status gate becomes trustworthy rather than merely fresh-looking. That is the fourth route by which this cache has produced a wrong answer, and the first where the stale read was inside MCP rather than here — the freshConfig accessor fixed this module's own reads but could not fix a gate that consults the cache independently. **The losing allowlist timer is cancelled.** Racing does not cancel the loser, so a lookup that succeeded in well under the bound still fired its timeout later and warned that it had timed out — on every normal attach and reuse. A fix whose only symptom was misleading logs, which is the kind that survives longest. 1 test pinning the ordering: the config read must precede the status gate, and it fails with the two swapped. The timer cancellation is verified by inspection — its only observable effect is a log line, and manufacturing a seam to assert on a log would be worse than the bug. --- packages/opencode/src/altimate/api/client.ts | 33 +++++++++------ .../src/altimate/workspace/engine-sync.ts | 40 ++++++++++++------- .../altimate/workspace/engine-sync.test.ts | 28 +++++++++++++ 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 98eb048d5d..c785b3fb99 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -238,20 +238,27 @@ export namespace AltimateApi { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 15_000) // altimate_change end - const res = await fetch(url, { - signal: controller.signal, - method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${creds.altimateApiKey}`, - "x-tenant": creds.altimateInstanceName, - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }).finally(() => clearTimeout(timeout)) - if (!res.ok) { - throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + try { + const res = await fetch(url, { + signal: controller.signal, + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${creds.altimateApiKey}`, + "x-tenant": creds.altimateInstanceName, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + } + // The abort stays armed until the BODY is read. `fetch` resolves on + // headers, so clearing it here would leave a server that sends headers and + // then stalls mid-body hanging indefinitely — with the socket held open. + return await res.json() + } finally { + clearTimeout(timeout) } - return res.json() } export async function listDatamates() { diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 8b6dd74bb1..14a781f934 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -423,19 +423,26 @@ async function announceToolsChanged(): Promise { * signal), so a stalled server releases its socket instead of accumulating * pending fetches across repair retries. */ async function declaredBounded(workspaceId: string): Promise { - return Promise.race([ - declared(workspaceId), - new Promise((resolve) => { - const timer = setTimeout(() => { - log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { - workspaceId, - timeoutMs: DECLARED_TIMEOUT_MS, - }) - resolve(null) - }, DECLARED_TIMEOUT_MS) - timer.unref?.() - }), - ]) + let timer: ReturnType | undefined + try { + return await Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) + } finally { + // Racing does not cancel the loser: left running, the timer fires later and + // warns about a lookup that had already succeeded, on every normal attach. + if (timer) clearTimeout(timer) + } } async function notify(toast: Toast): Promise { @@ -566,10 +573,15 @@ async function run(): Promise { log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) }) } + // Read the entry BEFORE asking for status. `existingEntry` refreshes the config + // cache and `MCP.status()` reads that same cache — so an entry an IDE or user + // added after the cache was warmed is missing from status entirely, `existing` + // is undefined, rule 1 never runs, and we persist our managed entry straight + // over theirs. Refreshing first is what makes the status gate trustworthy. + const entry = await existingEntry(DATAMATE_KEY) const before = await client.status() const existing = before[DATAMATE_KEY] if (existing) { - const entry = await existingEntry(DATAMATE_KEY) let connected = existing.status === "connected" if (!connected) { diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index fecb434513..ca175b8111 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1207,3 +1207,31 @@ describe("ensure — round 11", () => { expect(h.added).toHaveLength(0) }) }) + +describe("ensure — round 12", () => { + test("an externally added entry is seen even when MCP status has not caught up", async () => { + // MCP.status() reads the same cached config as everything else, so an entry + // an IDE adds after the cache is warm is absent from status. Without a fresh + // read first, rule 1 never runs and we persist over the user's entry. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], // status omits it + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, // but config has it + tools: { datamate_dbt_build_model: 1 }, + }) + let readBeforeStatus = false + let statusCalls = 0 + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + statusCalls += 1 + return realStatus() + } + syncInternals.existingEntry = async () => { + if (statusCalls === 0) readBeforeStatus = true + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + await ensure("s1") + // The ordering is the fix: the config refresh must precede the status gate. + expect(readBeforeStatus).toBe(true) + expect(h).toBeDefined() + }) +}) From 686e3bb2674633ad35b04ccd1a166f83fa59f8ff Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:44:16 +0800 Subject: [PATCH 16/67] fix(workspace): revalidate the binding before answering with a reused engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 13. One finding, verified, and it exposes a gap in how the earlier guards were reasoned about. Every mutation in this flow revalidates the binding before acting — the spawn, the teardown, the post-handshake check. Returning `reused` was treated as different because it changes nothing. It is not different: it ASSERTS that the connected engine serves the current binding, and the caller acts on that answer exactly as it acts on a spawn. The reuse branch also awaits the allowlist lookup first, so there is a real window in which a re-link can land. The consequence was the same one the attribution work exists to prevent: the turn snapshots the previous workspace's tools, and its credentials, under the new binding, and nothing corrects it until a later turn triggers replacement. The guard is not "revalidate before mutating". It is "revalidate before answering", because an answer this flow gives is acted on. 1 test: a re-link landing inside the reuse lookup yields `superseded` rather than the old workspace's tools, and it fails with the check removed. --- .../src/altimate/workspace/engine-sync.ts | 12 ++++++++++ .../altimate/workspace/engine-sync.test.ts | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 14a781f934..64d4f6c4bb 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -692,6 +692,18 @@ async function run(): Promise { variant: "warning", }) } + // Returning `reused` ASSERTS that the connected engine serves the + // current binding — and the lookup above can have waited. Every + // mutation already revalidates; so must this, because the caller acts + // on the answer just as surely. A re-link inside that await would + // otherwise hand this turn the previous workspace's tools, and its + // credentials, under the new binding. + if (!(await stillCurrent())) { + log.info("binding changed while reusing; abandoning rather than answering for the old workspace", { + workspaceId, + }) + return { kind: "superseded" } + } log.info("reusing existing engine entry", { workspaceId, available, diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index ca175b8111..55b0b2417a 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1235,3 +1235,25 @@ describe("ensure — round 12", () => { expect(h).toBeDefined() }) }) + +describe("ensure — round 13", () => { + test("a re-link during the reuse lookup is not answered with the old workspace", async () => { + // The reuse branch awaits the allowlist lookup for up to the bound. Returning + // `reused` afterwards asserts the connected engine serves the CURRENT binding + // — so a re-link inside that await would hand this turn workspace A's tools, + // and its credentials, under binding B. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added).toHaveLength(0) + }) +}) From ea3566b231b345f71a198f3c8635defd6b12ec8a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 05:58:00 +0800 Subject: [PATCH 17/67] fix(workspace): launch the version probe through cross-spawn, and re-check the floor on cached successes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 14. Two findings, both verified. **The version probe could not run a Windows shim.** `which` honours PATHEXT, so an npm-installed engine on Windows resolves to a `.cmd`, and Node cannot execute `.cmd` or `.bat` without a shell — the probe just errored. Every bound Windows user with an ordinary global install would have been told the engine was not runnable, while MCP's own launcher started that same engine without trouble. The probe now uses cross-spawn, which is what the rest of this repo already uses for exactly this reason. Worth noting the interaction: the previous round made an unreadable version report "not runnable rather than out of date", which was the right message and would have made this platform bug read as a confident, accurate diagnosis on every Windows machine. **A cached success now re-checks the floor, not just the pin.** The pin is only trustworthy because the floor is — engines below it do not lock the pin. An entry reconnected or replaced behind the same pin with a pre-floor binary rode the cached success indefinitely without passing through the attach flow again. Re-probed only when the entry's command changes, because probing spawns a process and this runs on every turn. The residual is narrow and stated rather than hidden: a binary swapped in place under an unchanged command is not noticed until the next session. That optimisation had a bug of its own, caught by its own test: the validated command was recorded on the outgoing entry while a fresh entry is built per call, so it was discarded and the probe ran every turn anyway. State that is not copied forward is state that is silently rebuilt. 3 tests: a cached success stops being trusted when the engine drops below the floor, an unchanged command is not re-probed per turn, and the existing suite pins the rest. --- .../src/altimate/workspace/engine-sync.ts | 77 ++++++++++++++++--- .../altimate/workspace/engine-sync.test.ts | 44 +++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 64d4f6c4bb..baddbb9229 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -56,7 +56,7 @@ // // Gated on the workspace pilot flag; inert without a local binding. -import { execFile } from "node:child_process" +import launch from "cross-spawn" import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" @@ -259,11 +259,33 @@ function which(cmd: string): string | null { function versionOf(bin: string): Promise { if (syncInternals.versionOf) return syncInternals.versionOf(bin) return new Promise((resolve) => { - execFile(bin, ["--version"], { timeout: 5000 }, (err, stdout) => { - if (err) return resolve(null) - const line = String(stdout).trim().split(/\r?\n/)[0] ?? "" - resolve(line || null) - }) + // cross-spawn, not execFile. An npm-installed engine on Windows is resolved + // by `which` to a `.cmd` shim (it honours PATHEXT), and Node cannot execute + // `.cmd` or `.bat` directly without a shell — the callback just errors. That + // would report "not runnable" to every bound Windows user with an ordinary + // global install, while MCP's own launcher started the same engine fine. + // This is the launcher the rest of the repo already uses for that reason. + let settled = false + const done = (value: string | null) => { + if (settled) return + settled = true + resolve(value) + } + try { + const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) + let out = "" + child.stdout?.on("data", (chunk) => { + out += String(chunk) + }) + child.on("error", () => done(null)) + child.on("close", (code) => { + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) + } catch { + done(null) + } }) } @@ -855,7 +877,14 @@ async function run(): Promise { * answers costs the first turn a pause rather than the turn itself. */ export const ATTACH_WAIT_MS = 15_000 -type SessionAttach = { key?: string; task: Promise; waitTimedOut?: boolean; outcome?: Outcome } +type SessionAttach = { + key?: string + task: Promise + waitTimedOut?: boolean + outcome?: Outcome + /** The entry argv whose version we last verified against the floor. */ + validated?: string +} /** Outcomes the user can repair without restarting: install the engine, update * it, fix a broken entry. Caching these for the life of the session means the @@ -887,14 +916,38 @@ function wasServing(outcome: Outcome | undefined): boolean { * reconnected until a new session or a re-link. * * Fails OPEN: a status read that throws must not invalidate a good attach. */ -async function engineStillOurs(workspaceId: string): Promise { +async function engineStillOurs(workspaceId: string, record?: SessionAttach): Promise { try { if ((await mcp().status())[DATAMATE_KEY]?.status !== "connected") return false // Connected is not enough. Link A -> B -> A with another session attaching B // in between, and this session's key matches its original memo while the // instance-wide client is serving B — so the cached success would expose B's // tools under binding A. The pin is what makes it ours. - return pinnedWorkspace(await existingEntry(DATAMATE_KEY)) === workspaceId + const entry = await existingEntry(DATAMATE_KEY) + if (pinnedWorkspace(entry) !== workspaceId) return false + + // The pin is not the whole contract: the FLOOR is what makes the pin + // trustworthy, since engines below it do not lock it. An entry reconnected + // or replaced behind the same pin with a pre-floor binary would otherwise + // ride the cached success forever, never passing through `run()` again. + // + // Re-probed only when the command CHANGES, because probing spawns a process + // and this runs every turn. The residual is narrow and worth naming: a + // binary swapped in place under an unchanged command is not caught until the + // next session. + const command = commandArgv(entry).join(" ") + if (record && record.validated === command) return true + const bin = commandArgv(entry)[0] + const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null + const found = direct ? await versionOf(direct) : null + if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { + log.info("cached attach no longer clears the version floor; re-attaching", { workspaceId, found }) + return false + } + // Recorded on the CURRENT entry — the one that will be remembered and copied + // forward. Writing it to the previous entry would be discarded next turn. + if (record) record.validated = command + return true } catch (err) { log.warn("could not re-probe the engine attribution; keeping the cached attach", { err: String(err) }) return true @@ -972,6 +1025,10 @@ export function ensure(sessionID: string): Promise { const entry = { key: previous?.key, waitTimedOut: previous?.waitTimedOut || repairRetry, + // Carried forward, or the version re-probe spawns a process every turn: a + // fresh entry is built per call, so state that is not copied is state that + // is silently rebuilt. + validated: previous?.validated, } as SessionAttach entry.task = (async (): Promise => { const key = await attachKey() @@ -982,7 +1039,7 @@ export function ensure(sessionID: string): Promise { if (sameWorkspace && !isRepairable(previous!.outcome)) { // Re-probe before trusting a cached success — see `engineStillConnected`. const boundTo = await attachKeyWorkspace() - if (!wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo))) return previous!.task + if (!wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo, entry))) return previous!.task log.info("cached attach is no longer connected; re-attaching", { sessionID }) } entry.key = key diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 55b0b2417a..7ac41717a7 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1257,3 +1257,47 @@ describe("ensure — round 13", () => { expect(h.added).toHaveLength(0) }) }) + +describe("ensure — round 14", () => { + test("a cached success stops being trusted if the engine drops below the floor", async () => { + // The pin is only trustworthy because the floor is: engines below it do not + // lock the pin. An entry reconnected behind the same pin with a pre-floor + // binary would otherwise ride the cached success forever. + let version = "0.7.0" + let command = ["datamate", "start-stdio", "--datamate", "42"] + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async () => version + syncInternals.existingEntry = async () => ({ type: "local", command }) + + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + + // The entry is replaced behind the same pin by an older engine. + command = ["/opt/old/datamate", "start-stdio", "--datamate", "42"] + version = "0.6.3" + const second = await ensure("s1") + expect(second).not.toBe(first) + }) + + test("an unchanged command is not re-probed every turn", async () => { + let probes = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async () => { + probes += 1 + return "0.7.0" + } + await ensure("s1") + const afterAttach = probes + await ensure("s1") + await ensure("s1") + // Probing spawns a process; the reuse path must not pay it on every turn. + expect(probes).toBeLessThanOrEqual(afterAttach + 1) + expect(h.added).toHaveLength(1) + }) +}) From 5a34902227f359d4bfac3cf1afb6baf47c92516a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 06:05:40 +0800 Subject: [PATCH 18/67] test(workspace): assert the attach contract as invariants, and fix the gap one found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the consolidation: the module's contract expressed as invariants rather than as one test per historical fix. A per-fix test says "this bug is gone"; an invariant says "this cannot happen", which is what catches the next instance of a class rather than the last one. Four fixes in this file's history created the following defect, and no per-fix test could have seen that. Six invariants, one per contract clause: one engine per project; no MCP mutation on a stale binding; every config read is fresh; an actionable failure is never silent; a superseded attach leaves nothing installed; a cached success is re-probed and re-attributed. The stale-binding one is a matrix that re-links the project at each await seam in turn, which is the shape that generalises. **One invariant was already violated, and that is the point.** The re-link matrix found a window fourteen review rounds had not: the guard after the engine add did not cover the tool listing that follows it, so a re-link during that read left the previous workspace installed and reported as attached. The two guards are now one, placed after every await that follows the install — late on purpose, since everything before the announcement is still revocable. Light dedupe, each subsumption proven by reverting the fix and confirming both tests fail: three per-fix tests dropped as covered by the invariants. Two candidates were kept because they are not covered — the pre-persist guard asserts the stronger "never installs at all" where the invariant only requires "never leaves it installed", and the floor re-check on a cached success is reached by no invariant. Coverage is not reduced. src 1,210 lines unchanged; tests 1,484 to 1,430 with six invariant suites added. The larger reduction belongs to Phase 2, where the guards themselves collapse into a transition function; deleting more tests before that would remove the net under it. --- .../src/altimate/workspace/engine-sync.ts | 37 +-- .../altimate/workspace/engine-sync.test.ts | 235 ++++++++++++++---- 2 files changed, 201 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index baddbb9229..baae5f0bc8 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -815,19 +815,6 @@ async function run(): Promise { await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) - // `client.add` waits for the MCP handshake, which can run to the connection - // timeout — an unchecked window the pre-mutation guard cannot cover. A re-link - // inside it leaves us having just installed the workspace this session left, - // and serialization means we installed it FIRST, so the replacement queues - // behind us while the waiting turn's budget drains. Undo it rather than return. - if (!(await stillCurrent())) { - log.info("binding changed during the engine handshake; removing what we just installed", { workspaceId }) - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not remove the superseded engine", { err: String(err) }) - }) - return { kind: "superseded" } - } - // Rule 4 — a failed local engine is reported, never routed around. const after = (await client.status())[DATAMATE_KEY] if (after?.status !== "connected") { @@ -840,14 +827,30 @@ async function run(): Promise { return { kind: "connect-failed", error } } - // The add succeeded and is ours: announce it, so a turn that had already given - // up waiting still learns the tools arrived. - await announceToolsChanged() - // Rule 5 — report declared-but-missing. const present = engineToolKeys(await client.tools()) const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] const available = present.size + // ONE guard, placed after every await that follows the install — the handshake + // AND the tool listing. Both are windows in which a re-link can land, and the + // earlier version guarded only the first, so a flip during the tool read left + // the previous workspace installed and reported as attached. + // + // Late rather than early on purpose: the check is only meaningful at the last + // moment before we announce and answer, because everything before that is + // still revocable. + if (!(await stillCurrent())) { + log.info("binding changed before the attach could be reported; removing what we installed", { workspaceId }) + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not remove the superseded engine", { err: String(err) }) + }) + return { kind: "superseded" } + } + + // Ours, and staying: announce it so a turn that had already given up waiting + // still learns the tools arrived. + await announceToolsChanged() + await notify({ title: `Workspace "${binding.datamateName}" connected`, message: diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 7ac41717a7..09468f26cf 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1008,42 +1008,7 @@ describe("ensure — round 8", () => { expect(h.added).toHaveLength(0) }) - test("a re-link DURING the engine add is caught after it completes", async () => { - let current: CachedBinding | null = binding // 42 - const h = install({ - statuses: [{}, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }) - syncInternals.resolveBinding = async () => current - // The re-link lands while the MCP handshake is in flight. - syncInternals.mcp!.add = async (name, cfg) => { - h.added.push({ name, cfg }) - current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding - } - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "superseded" }) - // The client we installed for the workspace we left must not stay serving. - expect(h.removes).toEqual(["datamate"]) - }) - test("a cached SUCCESS is re-probed: a died engine re-attaches", async () => { - const h = install({ - statuses: [ - {}, - { datamate: { status: "connected" } }, - { datamate: { status: "failed", error: "Connection closed" } }, - {}, - { datamate: { status: "connected" } }, - ], - tools: { datamate_dbt_build_model: 1 }, - }) - const first = await ensure("s1") - expect(first).toMatchObject({ kind: "attached" }) - // The engine's child exits; MCP marks it failed. The next turn must notice. - const second = await ensure("s1") - expect(second).not.toBe(first) - expect(h.added).toHaveLength(2) - }) test("settled project attach chains are not retained", async () => { install({ binding: null }) @@ -1139,25 +1104,6 @@ describe("ensure — round 10", () => { expect(h.toolsChanged).toBe(1) }) - test("a cached success is re-attributed, not merely re-connected", async () => { - // A -> B -> A while another session attached B: the key matches this - // session's original memo and the client is connected, but it is serving B. - let pinned = "42" - const h = install({ - statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }) - syncInternals.existingEntry = async () => ({ - type: "local", - command: ["datamate", "start-stdio", "--datamate", pinned], - }) - const first = await ensure("s1") - expect(first).toMatchObject({ kind: "attached" }) - - pinned = "99" // the live entry now serves another workspace - const second = await ensure("s1") - expect(second).not.toBe(first) - }) test("a stalled catalog lookup cannot block the local engine", async () => { const h = install({ @@ -1301,3 +1247,184 @@ describe("ensure — round 14", () => { expect(h.added).toHaveLength(1) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// INVARIANTS +// +// These assert the module's contract rather than the shape of any one fix. A +// per-fix test says "this bug is gone"; an invariant says "this cannot happen", +// which is what catches the NEXT instance of a class rather than the last one. +// Four fixes in this file's history created the following defect, and no per-fix +// test could have seen that. These are the net underneath the next change. +// ───────────────────────────────────────────────────────────────────────────── +describe("INVARIANT — one engine per project", () => { + test("a replacement never leaves two engines registered: every add over a live entry is preceded by a removal", async () => { + const live: Array<{ name: string; existing: Harness["statusQueue"][number]; entry: unknown }> = [ + { name: "unpinned", existing: { datamate: { status: "connected" } }, entry: { type: "local", command: ["datamate", "start-stdio"] } }, + { name: "pinned elsewhere", existing: { datamate: { status: "connected" } }, entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] } }, + { name: "connected url", existing: { datamate: { status: "connected" } }, entry: { type: "remote", url: "https://api.example/sse" } }, + ] + for (const scenario of live) { + resetForTests() + const h = install({ + existing: scenario.entry as never, + statuses: [scenario.existing, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure(`s-${scenario.name}`) + if (h.added.length > 0) { + expect(h.removes.length, `${scenario.name}: added without removing the live entry first`).toBeGreaterThan(0) + } + } + }) + + test("concurrent attaches in one project never overlap their mutating phase", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let inFlight = 0 + let peak = 0 + syncInternals.mcp!.add = async (name, cfg) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise((r) => setTimeout(r, 30)) + h.added.push({ name, cfg }) + inFlight -= 1 + } + await Promise.all([ensure("a"), ensure("b")]) + expect(peak).toBe(1) + }) +}) + +describe("INVARIANT — no MCP mutation on a stale binding", () => { + // The binding is flipped at each await seam in turn. Whatever the flow was + // doing, it must not mutate MCP state for a workspace the project has left. + const seams = ["existingEntry", "versionOf", "declared", "tools", "add"] as const + + for (const seam of seams) { + test(`a re-link at the ${seam} seam never installs or tears down for the old workspace`, async () => { + resetForTests() + let current: CachedBinding | null = binding // 42 + const flip = () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + if (seam === "existingEntry") syncInternals.existingEntry = async () => (flip(), null) + if (seam === "versionOf") syncInternals.versionOf = async () => (flip(), "0.7.0") + if (seam === "declared") syncInternals.declared = async () => (flip(), { keys: [], extensionKeys: [] }) + if (seam === "tools") syncInternals.mcp!.tools = async () => (flip(), {}) + if (seam === "add") { + const prev = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prev(n, c) + flip() + } + } + await ensure("s1") + // Anything installed for 42 after the project moved to 99 must not survive. + const strayFor42 = h.added.filter((a) => a.cfg.command.includes("42")).length + if (strayFor42 > 0) { + expect(h.removes.length, `${seam}: installed workspace 42 after the re-link and left it`).toBeGreaterThan(0) + } + }) + } +}) + +describe("INVARIANT — every config read is fresh", () => { + test("no config read bypasses the refreshing accessor", async () => { + let fresh = 0 + const h = install({ statuses: [{ datamate: { status: "disabled" } }] }) + syncInternals.existingEntry = undefined + syncInternals.freshConfig = async () => { + fresh += 1 + return { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } + } + await ensure("s1") + // If any read went through a cached path instead, this would be 0. + expect(fresh).toBeGreaterThan(0) + expect(h.connects).toHaveLength(0) + }) +}) + +describe("INVARIANT — an actionable failure always tells the user", () => { + const actionable: Array<{ name: string; opts: Parameters[0]; kind: string }> = [ + { name: "engine-missing", opts: { which: null }, kind: "engine-missing" }, + { name: "engine-too-old", opts: { version: "0.5.9" }, kind: "engine-too-old" }, + { name: "unrunnable engine", opts: { version: null }, kind: "engine-too-old" }, + { + name: "entry-disabled", + opts: { + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, + statuses: [{ datamate: { status: "disabled" } }], + }, + kind: "entry-disabled", + }, + { + name: "connect-failed", + opts: { + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + }, + kind: "connect-failed", + }, + ] + for (const c of actionable) { + test(`${c.name} is never silent`, async () => { + resetForTests() + const h = install(c.opts) + const outcome = await ensure("s1") + expect(outcome.kind).toBe(c.kind as never) + expect(h.toasts.length, `${c.name} returned without telling the user`).toBeGreaterThan(0) + }) + } +}) + +describe("INVARIANT — a superseded attach leaves nothing installed", () => { + test("whatever it installed before noticing, it does not leave it serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prevAdd(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "superseded left the engine it installed still registered").toContain("datamate") + }) +}) + +describe("INVARIANT — a cached success is re-probed and re-attributed", () => { + const invalidations = [ + { name: "engine died", statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "failed", error: "closed" } }, {}, { datamate: { status: "connected" } }], entry: null }, + { name: "pin moved to another workspace", statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], entry: "99" }, + ] as const + + for (const c of invalidations) { + test(`a cached success is not reused when the ${c.name}`, async () => { + resetForTests() + let pin = "42" + const h = install({ statuses: c.statuses as never, tools: { datamate_dbt_build_model: 1 } }) + if (c.entry !== null) { + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", pin] }) + } + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + if (c.entry !== null) pin = c.entry + const second = await ensure("s1") + expect(second, `${c.name}: the cached success was reused unchecked`).not.toBe(first) + }) + } +}) From fd5f2f8b67e5e3f651eeb6ec0173c4754774a29c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 06:22:42 +0800 Subject: [PATCH 19/67] fix(workspace): stop reporting a foreign-pinned gateway as the datamate you asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 15. One finding, verified, and it is a consequence of this branch rather than something it inherited. Before this work the shared gateway key was generic: whatever was connected under it answered for any datamate, so "already configured and connected" was a true answer to "add datamate N". Pinning that same key to one workspace made the key workspace-specific while the manager tool kept treating it as generic. After workspace 42 attaches, asking to add datamate 99 reported 99 as connected while the runtime went on serving 42's tools — and 42's credentials. The gateway entry's pin is now checked. An unpinned entry is still the generic gateway and answers for any datamate, which keeps the extension-written shape working. A pin for a different workspace is replaced rather than reported as a success, which is what naming a datamate explicitly asks for. The two remedies suggested were a separate key for the pinned process, or validating the pin here. A separate key is not viable: reusing this key is what lets an IDE-started engine be adopted at all, and rule 1 is built on it. So the check belongs where the wrong assumption lives. The decision is extracted as `isPinnedToOtherWorkspace` and unit-tested directly. That is deliberate and worth stating: `handleAdd` has no test harness — it needs MCP, config and transport — and building one to reach a three-line predicate would have been a worse trade than testing the predicate. The wiring around it is covered by inspection, not by test, and the tests assert the decision, including that an unpinned entry keeps answering for any datamate. --- .../opencode/src/altimate/tools/datamate.ts | 38 +++++++++++++++++-- .../opencode/test/altimate/datamate.test.ts | 32 +++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index d6d6d16992..547428dca3 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -8,11 +8,13 @@ import { listMcpInConfig, resolveConfigPath, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +import { pinnedWorkspace } from "../workspace/engine-sync" const log = Log.create({ service: "datamate" }) @@ -190,6 +192,19 @@ async function handleListIntegrations() { // DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant). +/** Is the configured gateway entry pinned to a DIFFERENT workspace than the one + * being asked for? + * + * The workspace attach persists this shared key with `--datamate `, so + * "configured and connected" stopped meaning "serving whatever you asked for". + * An unpinned entry is the generic gateway and still answers for any datamate; + * a pin for another workspace does not, and saying otherwise would report success + * while the runtime served another workspace's tools and credentials. */ +export function isPinnedToOtherWorkspace(entry: unknown, datamateId: string | number): boolean { + const pin = pinnedWorkspace((entry ?? null) as never) + return pin !== null && pin !== String(datamateId) +} + async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { return { @@ -251,8 +266,24 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) } - if (existingNames.includes(DATAMATE_KEY)) { - // Already in config — just ensure it is connected in this session + // The workspace attach persists this same key PINNED to one workspace + // (`--datamate `), so "configured and connected" no longer means "serving + // whatever you asked for". Reporting success here would tell the user their + // datamate is connected while the runtime kept serving another workspace's + // tools — and its credentials. A pin for a different workspace is replaced, + // which is what the user asked for by naming a datamate explicitly. + const configuredEntry = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) + const pinnedElsewhere = isPinnedToOtherWorkspace(configuredEntry, args.datamate_id) + if (pinnedElsewhere) { + log.info("handleAdd: existing entry is pinned to another workspace; replacing", { + serverName: DATAMATE_KEY, + pinnedTo: pinnedWorkspace((configuredEntry ?? null) as never), + requested: args.datamate_id, + }) + } + + if (existingNames.includes(DATAMATE_KEY) && !pinnedElsewhere) { + // Already in config for THIS datamate — just ensure it is connected. const allStatus = await MCP.status() if (allStatus[DATAMATE_KEY]?.status === "connected") { log.info("handleAdd: already connected, skipping add", { @@ -281,7 +312,8 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) await MCP.connect(DATAMATE_KEY) } else { - // Not in config yet — write to disk then connect + // Not in config yet, or pinned to a workspace other than the one asked + // for — write to disk then connect, replacing the pin either way. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts index 50ff9ad2a8..c4bbd324c5 100644 --- a/packages/opencode/test/altimate/datamate.test.ts +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -4,7 +4,7 @@ import os from "os" import fsp from "fs/promises" import { AltimateApi } from "../../src/altimate/api/client" -import { slugify } from "../../src/altimate/tools/datamate" +import { slugify, isPinnedToOtherWorkspace } from "../../src/altimate/tools/datamate" // --------------------------------------------------------------------------- // Helpers @@ -589,3 +589,33 @@ describe("slugify", () => { afterEach(async () => { await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) }) + +describe("isPinnedToOtherWorkspace", () => { + // The workspace attach persists the shared gateway key pinned to one + // workspace, so "already configured and connected" stopped implying "serving + // the datamate you asked for". Reporting success in that case would tell the + // user their datamate is connected while another workspace's tools — and + // credentials — were the ones actually exposed. + test("a pin for another workspace is not the gateway you asked for", () => { + const entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + expect(isPinnedToOtherWorkspace(entry, "99")).toBe(true) + expect(isPinnedToOtherWorkspace(entry, 99)).toBe(true) + }) + + test("a pin for the requested workspace is", () => { + const entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + expect(isPinnedToOtherWorkspace(entry, "42")).toBe(false) + expect(isPinnedToOtherWorkspace(entry, 42)).toBe(false) + }) + + test("an UNPINNED entry is the generic gateway and answers for any datamate", () => { + // This is the pre-existing extension-written shape; it must keep working. + expect(isPinnedToOtherWorkspace({ type: "local", command: ["datamate", "start-stdio"] }, "99")).toBe(false) + expect(isPinnedToOtherWorkspace({ command: "datamate", args: ["start-stdio"] }, "99")).toBe(false) + }) + + test("a missing entry is not treated as a foreign pin", () => { + expect(isPinnedToOtherWorkspace(undefined, "99")).toBe(false) + expect(isPinnedToOtherWorkspace(null, "99")).toBe(false) + }) +}) From 42fb81633a15e0b18c236cb5f102ddd1367030f2 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 06:51:52 +0800 Subject: [PATCH 20/67] fix(workspace): honour a config disable regardless of runtime connectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 16. One finding, verified, and it is the mirror of round 9's. Round 9 handled the runtime saying "disabled" while the config said enabled — a synthesized status that a teardown produces. This is the reverse: the config says `enabled: false` while MCP still reports "connected" from live client state, because an IDE or a direct edit can disable the entry without stopping the running client. The disable check was nested inside the not-connected branch, so that case was skipped entirely — and for an unpinned entry the replacement path below would then have persisted it enabled again, undoing the very edit the user had just made. The check is lifted above the connectivity branch. The config's `enabled` flag is the only place a user expresses "off", so it is consulted before anything else; runtime connectivity answers a different question and cannot stand in for intent. The two sources disagree in both directions, and each direction cost a round to find. That is the argument for reading intent from one authority rather than inferring it from whatever signal is nearest. Residual, named rather than hidden: a client that is already connected keeps serving until MCP drops it. This flow stops attaching and stops re-enabling, but it does not tear down a live client on the strength of someone else's config edit. 1 test: a config disable is honoured while the runtime still reports connected — no attach, no persist, no reconnect. It fails with the check re-nested. --- .../src/altimate/workspace/engine-sync.ts | 58 +++++++++---------- .../altimate/workspace/engine-sync.test.ts | 19 ++++++ 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index baae5f0bc8..c53371e2e3 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -606,36 +606,36 @@ async function run(): Promise { if (existing) { let connected = existing.status === "connected" + // Intent first, connectivity second. The config's `enabled` flag is the + // only place a user expresses "off", and the two sources disagree in BOTH + // directions: `MCP.status()` synthesizes "disabled" for a configured entry + // that has no runtime status (so a teardown looks like a user disable), and + // it keeps reporting "connected" from live client state after the config + // has been set to disabled (so a real disable looked like nothing at all). + // Gating on connectivity missed the second case entirely — and for an + // unpinned entry the replacement path below would then have persisted it + // enabled again, undoing the very edit the user made. + // + // `existingEntry` is always fresh, so `entry` already reflects disk. + if (entry?.enabled === false) { + // The user turned this entry off deliberately. Do NOT call `MCP.connect` + // to "retry" it: that persists `enabled: true` into whichever config + // owns the entry, so for a global `datamate` the first prompt in any + // bound project would silently re-enable it for every other project. + // Say what is unavailable and leave their choice alone. + log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) + await notify({ + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }) + return { kind: "entry-disabled" } + } + + if (!connected) { - // `MCP.status()` synthesizes "disabled" for any CONFIGURED entry that has - // no runtime status, and `MCP.remove` deletes the status — so every - // rejection teardown makes the next turn look like a user disable. Read the - // config's actual flag instead; only that is user intent. - // The runtime status is authoritative for "not running"; the CONFIG is - // authoritative for "the user turned it off" — and the two can disagree. - // `MCP.disconnect` writes `enabled: false` to disk WITHOUT invalidating - // Config, so a cached entry still says `enabled: true` immediately after a - // user disconnects. Treating that as a synthesized status would reconnect - // and persist it enabled again, undoing their disconnect — globally, if - // the owning entry is global. Read the file before deciding. - // `existingEntry` is always fresh now, so `entry` already reflects disk. - const owning = entry - if (existing.status === "disabled" && owning?.enabled === false) { - // The user turned this entry off deliberately. Do NOT call `MCP.connect` - // to "retry" it: that persists `enabled: true` into whichever config - // owns the entry, so for a global `datamate` the first prompt in any - // bound project would silently re-enable it for every other project. - // Say what is unavailable and leave their choice alone. - log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) - await notify({ - title: "Workspace engine is disabled", - message: - `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + - `integration tools are unavailable. Enable it to use them.`, - variant: "warning", - }) - return { kind: "entry-disabled" } - } if (isUrlEntry(entry)) { // Dead URL: nothing here can bring that process back — only the IDE can // restore its port. Fall through to a local spawn and report it below. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 09468f26cf..2ced193dba 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1428,3 +1428,22 @@ describe("INVARIANT — a cached success is re-probed and re-attributed", () => }) } }) + +describe("ensure — round 16", () => { + test("a config disable is honoured even while the runtime is still connected", async () => { + // The mirror of the round-9 case. There the runtime said disabled and the + // config said enabled; here the config says disabled and the RUNTIME still + // says connected, because MCP reports live client state. Gating the disable + // check on connectivity skipped it entirely — and for an unpinned entry the + // replacement path would then persist it enabled again, undoing the disable. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.added, "attached over an entry the user had disabled").toHaveLength(0) + expect(h.persisted, "re-enabled an entry the user had disabled").toHaveLength(0) + expect(h.connects).toHaveLength(0) + }) +}) From 3597444766a2598573ba82d56e9b65c14182954b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:02:27 +0800 Subject: [PATCH 21/67] fix(workspace): make "superseded" actually undo the attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 17. Two findings, both gaps in the supersede path this branch added, and both exposed by strengthening an invariant that had been passing while they existed. **A superseded reuse now detaches, rather than only declining.** Returning `superseded` left the previous workspace's client registered, and the caller runs resolveTools whatever the outcome is — so that turn was handed the old workspace's tools and credentials regardless. The outcome is advice; the registration is what the model actually sees. Declining to answer is not the same as not answering. **A superseded attach now restores the config, not just the runtime.** persist() commits the pin before the engine is known to be ours, so undoing only the runtime client left the abandoned workspace pinned on disk — and MCP bootstraps every enabled entry, so a restart before the next attach would start the workspace we had just walked away from. The previous entry is put back, or ours removed if there was none. The invariant that should have caught both asserted only that the runtime client was removed. It passed while a stale pin sat on disk and while the reuse path detached nothing. An invariant is only as good as its definition of "nothing": it now covers the config and the reuse path, and each half fails independently when its fix is reverted. That is the second time an invariant has been the thing that found the gap, and the first time one of them was itself too weak — worth remembering when Phase 2 leans on them. --- .../src/altimate/workspace/engine-sync.ts | 36 +++++++++++++++++-- .../altimate/workspace/engine-sync.test.ts | 28 +++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index c53371e2e3..e6c66b0031 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -62,7 +62,7 @@ import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import { MCP, ToolsChanged } from "@/mcp" -import { addMcpToConfig, resolveConfigPath } from "@/mcp/config" +import { addMcpToConfig, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" import { Config } from "@/config/config" import { AltimateApi } from "@/altimate/api/client" import { DATAMATE_KEY } from "@/altimate/datamate-transport" @@ -133,6 +133,7 @@ export const syncInternals: { tools: () => Promise> } persist?: (name: string, cfg: LocalMcpConfig) => Promise + persistRestore?: (name: string, previous: ExistingEntry | null) => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise freshConfig?: () => Promise<{ mcp?: Record }> @@ -336,6 +337,25 @@ async function freshConfig(): Promise<{ mcp?: Record } } +/** Put the config back the way we found it. + * + * `persist()` commits the pin BEFORE the engine is known to be ours, so a + * supersede after that point leaves the abandoned workspace pinned on disk — + * and MCP bootstraps every enabled entry, so a restart before the next attach + * would start the workspace we just walked away from. Removing the runtime + * client is only half of undoing an attach. */ +async function persistRestore(name: string, previous: ExistingEntry | null): Promise { + if (syncInternals.persistRestore) return syncInternals.persistRestore(name, previous) + try { + const configPath = await resolveConfigPath(projectRoot()) + if (previous) await addMcpToConfig(name, previous as never, configPath) + else await removeMcpFromConfig(name, configPath) + await Config.invalidate().catch(() => undefined) + } catch (err) { + log.warn("could not restore the config after a superseded attach", { name, err: String(err) }) + } +} + async function existingEntry(name: string): Promise { if (syncInternals.existingEntry) return syncInternals.existingEntry(name) try { @@ -721,9 +741,16 @@ async function run(): Promise { // otherwise hand this turn the previous workspace's tools, and its // credentials, under the new binding. if (!(await stillCurrent())) { - log.info("binding changed while reusing; abandoning rather than answering for the old workspace", { + // Detach, do not merely decline. The caller runs `resolveTools` + // whatever this returns, so leaving the old client registered hands + // that turn the previous workspace's tools and credentials anyway — + // the outcome is advice, the registration is what the model sees. + log.info("binding changed while reusing; detaching rather than answering for the old workspace", { workspaceId, }) + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the superseded engine", { err: String(err) }) + }) return { kind: "superseded" } } log.info("reusing existing engine entry", { @@ -840,10 +867,13 @@ async function run(): Promise { // moment before we announce and answer, because everything before that is // still revocable. if (!(await stillCurrent())) { - log.info("binding changed before the attach could be reported; removing what we installed", { workspaceId }) + log.info("binding changed before the attach could be reported; undoing what we installed", { workspaceId }) await client.remove(DATAMATE_KEY).catch((err) => { log.warn("could not remove the superseded engine", { err: String(err) }) }) + // And the config: `persist()` committed the pin before the engine was known + // to be ours, and bootstrap starts every enabled entry. + await persistRestore(DATAMATE_KEY, entry) return { kind: "superseded" } } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 2ced193dba..1be2cef043 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -40,6 +40,7 @@ type Harness = { removes: string[] toasts: Array<{ title: string; message: string; variant: string }> toolsChanged: number + restores: Array statusQueue: Array> tools: Record } @@ -60,6 +61,7 @@ function install(opts: { removes: [], toasts: [], toolsChanged: 0, + restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, } @@ -88,6 +90,9 @@ function install(opts: { syncInternals.toolsChanged = async () => { h.toolsChanged += 1 } + syncInternals.persistRestore = async (_name, previous) => { + h.restores.push(previous ?? null) + } syncInternals.mcp = { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => { @@ -1403,6 +1408,29 @@ describe("INVARIANT — a superseded attach leaves nothing installed", () => { const outcome = await ensure("s1") expect(outcome).toEqual({ kind: "superseded" }) expect(h.removes, "superseded left the engine it installed still registered").toContain("datamate") + // The runtime is only half of it. `persist()` already wrote the old + // workspace's pin to disk, so a restart before the next attach would + // bootstrap it again — "leaves nothing installed" has to mean the config too. + expect(h.restores.length, "superseded left the old workspace pinned on disk").toBeGreaterThan(0) + }) + + test("a superseded REUSE detaches the engine it declined to answer with", async () => { + // The caller runs resolveTools regardless of the outcome, so returning + // `superseded` while the old client stays registered still hands that turn + // the previous workspace's tools — and its credentials. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.removes, "left the old workspace's client registered for resolveTools to find").toContain("datamate") }) }) From 37dd23dd378dda0bcc6d705f52c08fd6093bfc92 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:12:35 +0800 Subject: [PATCH 22/67] fix(workspace): revalidate after cached-success validation, and restore the project entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 18. Two findings, both verified, both proven by revert. **The memoised-success path needed the final binding check too.** Validating a cached success is itself awaited work — status, config, and sometimes a version probe — so the binding can move underneath it. That path lives in `ensure()`, outside `run()`, and so never had run's closing check: a confirmed-valid engine for the workspace just left was returned as the answer for the one just joined, and the turn took its tools and credentials. This is the same rule that has now been applied in four places: revalidate before answering, because an answer this flow gives is acted on. The rule was right; it had not been carried to the one path that does its validating elsewhere. **A superseded attach now restores the project entry, not the merged one.** `existingEntry()` returns the merged view, which may come from global config, while `persist()` writes to the project file. Restoring the merged value wrote a copy of the global entry into the project — a permanent override shadowing every later global update, disable or removal, produced by an attach that was meant to leave configuration untouched. The project file's own entry is snapshotted before persisting and that is what goes back, which means removing the override when there was nothing there before. Undoing a write is only correct if it restores what that write replaced, and the thing replaced was never the merged view. --- .../src/altimate/workspace/engine-sync.ts | 35 ++++++++++++-- .../altimate/workspace/engine-sync.test.ts | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e6c66b0031..c3742a9937 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -62,7 +62,7 @@ import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import { MCP, ToolsChanged } from "@/mcp" -import { addMcpToConfig, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" +import { addMcpToConfig, readMcpEntryFromDisk, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" import { Config } from "@/config/config" import { AltimateApi } from "@/altimate/api/client" import { DATAMATE_KEY } from "@/altimate/datamate-transport" @@ -134,6 +134,7 @@ export const syncInternals: { } persist?: (name: string, cfg: LocalMcpConfig) => Promise persistRestore?: (name: string, previous: ExistingEntry | null) => Promise + projectEntry?: () => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise freshConfig?: () => Promise<{ mcp?: Record }> @@ -337,6 +338,24 @@ async function freshConfig(): Promise<{ mcp?: Record } } +/** The entry in the PROJECT config only, not the merged view. + * + * `existingEntry()` returns the merged value, which may come from global config, + * while `persist()` writes to the project file. Restoring the merged value would + * write a copy of the global entry into the project — a permanent override that + * shadows every later global update, disable or removal, from an attach that was + * meant to leave configuration untouched. */ +async function projectEntry(): Promise { + if (syncInternals.projectEntry) return syncInternals.projectEntry() + try { + const configPath = await resolveConfigPath(projectRoot()) + return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null + } catch (err) { + log.warn("could not read the project-level engine entry", { err: String(err) }) + return null + } +} + /** Put the config back the way we found it. * * `persist()` commits the pin BEFORE the engine is known to be ours, so a @@ -839,6 +858,9 @@ async function run(): Promise { log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) return { kind: "superseded" } } + // Snapshot what persist() is about to overwrite — the project file's own + // entry, not the merged view — so a supersede can put back exactly that. + const projectBefore = await projectEntry() await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) @@ -873,7 +895,7 @@ async function run(): Promise { }) // And the config: `persist()` committed the pin before the engine was known // to be ours, and bootstrap starts every enabled entry. - await persistRestore(DATAMATE_KEY, entry) + await persistRestore(DATAMATE_KEY, projectBefore) return { kind: "superseded" } } @@ -1072,7 +1094,14 @@ export function ensure(sessionID: string): Promise { if (sameWorkspace && !isRepairable(previous!.outcome)) { // Re-probe before trusting a cached success — see `engineStillConnected`. const boundTo = await attachKeyWorkspace() - if (!wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo, entry))) return previous!.task + const reusable = + !wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo, entry)) + // Validating the cached success is itself awaited work — status, config and + // possibly a version probe — so the binding can move underneath it. This + // path lives outside `run()` and therefore never had its final check; + // without one, a confirmed-valid engine for the workspace we just left is + // returned as the answer for the one we just joined. + if (reusable && (await attachKeyWorkspace()) === boundTo) return previous!.task log.info("cached attach is no longer connected; re-attaching", { sessionID }) } entry.key = key diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 1be2cef043..582ade7817 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1475,3 +1475,51 @@ describe("ensure — round 16", () => { expect(h.connects).toHaveLength(0) }) }) + +describe("ensure — round 18", () => { + test("a re-link DURING cached-success validation is not answered with the old workspace", async () => { + // The memoised-success path does its own awaited validation outside run(), + // so it never had run()'s final binding check. Status, config and version + // work all await; a re-link inside them left `boundTo` pointing at the old + // workspace and returned its cached task — handing the turn A's tools and + // credentials under binding B. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + + // The re-link lands while the cached success is being re-validated. + syncInternals.versionOf = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return "0.7.0" + } + const second = await ensure("s1") + expect(second, "returned the cached success for a workspace the project had left").not.toBe(first) + }) + + test("a superseded attach removes the project override rather than copying the global entry", async () => { + // existingEntry() returns the MERGED value, which may come from global, while + // persist() writes to the project file. Restoring the merged value would + // write a copy of the global entry into the project — a permanent override + // shadowing every later global update, disable or removal. + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, // merged, from global + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => null // the PROJECT file has no entry of its own + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prevAdd(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.restores, "restored something into the project file instead of removing the override").toEqual([null]) + }) +}) From ce5331d442dcca31a88c296b5177af5307c2b535 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:30:05 +0800 Subject: [PATCH 23/67] fix(workspace): tear down a disabled engine, and close the seam the restore fix opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, each proven by individual revert. A disabled entry stopped at the config. `MCP.status()` reports live client state and `MCP.tools()` gates on exactly that status — it reads the config only for a timeout — so an entry disabled AFTER it connected kept exporting its tools and credentials to `resolveTools`. The branch already documented this in a comment and still returned without touching the runtime. It now detaches through the existing rejection path, which is runtime-only and writes no config: respecting the edit, not re-applying it. The same check was unreachable from the memoised-success path. Validation covered connectivity, pin and version but never `enabled`, so a session that had already attached rode its memo past a disable for the rest of its life. The check goes in ahead of the command-unchanged shortcut, and returns false rather than detaching directly — routing the session back through `run()`, where the reporting and the teardown already live. The third was introduced by the previous commit: snapshotting the project entry for a restore put an awaited disk read between the final binding check and the install it guards. The late guard would undo the stale attach, but only after spawning an engine and taking the per-project lock — long enough for the replacement's first-turn wait to expire, which is the failure that guard exists to prevent. The snapshot moves above the check, so nothing awaits between the check and the mutations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 33 +++++++++- .../altimate/workspace/engine-sync.test.ts | 66 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index c3742a9937..aaa4fc9b23 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -663,6 +663,15 @@ async function run(): Promise { // bound project would silently re-enable it for every other project. // Say what is unavailable and leave their choice alone. log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) + // Leaving the CONFIG alone is the whole point; leaving the RUNTIME alone is + // not. `MCP.status()` reports live client state, and `MCP.tools()` gates on + // exactly that status without consulting `enabled` — so an entry disabled + // after it connected keeps exporting its tools to `resolveTools`, and the + // user's "off" is honoured on disk while the model still holds the + // workspace's tools and credentials. `remove` is runtime-only: it closes + // the client and publishes ToolsChanged without writing config, which is + // precisely "respect the edit", not "re-apply" it. + await detachRejected({ reason: "the entry is disabled" }) await notify({ title: "Workspace engine is disabled", message: @@ -852,15 +861,23 @@ async function run(): Promise { command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], enabled: true, } + // Snapshot what persist() is about to overwrite — the project file's own + // entry, not the merged view — so a supersede can put back exactly that. + // + // Read BEFORE the guard rather than between it and the writes. Every await + // after the last check reopens the window that check exists to close, and a + // disk read is a wide one. The post-install guard would undo the stale attach, + // but only after it had spawned an engine and held the per-project lock — long + // enough for the replacement's first-turn wait to expire, which is the failure + // the guard was added to prevent. Nothing may await between the guard and the + // mutations it guards. + const projectBefore = await projectEntry() if (!(await stillCurrent())) { // Re-linked while we were probing. Installing now would attach the workspace // this session has already left, and would win by arriving first. log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) return { kind: "superseded" } } - // Snapshot what persist() is about to overwrite — the project file's own - // entry, not the merged view — so a supersede can put back exactly that. - const projectBefore = await projectEntry() await persist(DATAMATE_KEY, cfg) await client.add(DATAMATE_KEY, cfg) @@ -979,6 +996,16 @@ async function engineStillOurs(workspaceId: string, record?: SessionAttach): Pro // instance-wide client is serving B — so the cached success would expose B's // tools under binding A. The pin is what makes it ours. const entry = await existingEntry(DATAMATE_KEY) + // Intent outranks every other check, and it is checked FIRST because the + // command-unchanged shortcut below returns early: a session that already + // attached would otherwise ride its memo straight past the disable for the + // rest of its life, never re-entering `run()` where the check lives. + // Returning false here does not itself detach — it routes this session back + // through `run()`, which reports `entry-disabled` and tears the client down. + if (entry?.enabled === false) { + log.info("engine entry was disabled since the cached attach; re-deciding", { workspaceId }) + return false + } if (pinnedWorkspace(entry) !== workspaceId) return false // The pin is not the whole contract: the FLOOR is what makes the pin diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 582ade7817..4d6af05bb9 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1523,3 +1523,69 @@ describe("ensure — round 18", () => { expect(h.restores, "restored something into the project file instead of removing the override").toEqual([null]) }) }) + +describe("INVARIANT — a disabled entry serves nothing", () => { + // "Disabled" is a claim about what the model can reach, not about what the + // config file says. The config is where the user expresses it; the runtime is + // where it either holds or doesn't. + test("an entry disabled AFTER it connected is torn down, not merely reported", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + // The status a live disable actually produces. `MCP.status()` returns live + // client state and `MCP.tools()` gates on exactly that, consulting the + // config only for a timeout — so reporting `entry-disabled` while the + // client stays registered hands that turn the tools and the credentials + // of the workspace the user just switched off. + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.removes, "reported the entry disabled but left its client serving tools").toContain("datamate") + // Respecting the edit must not turn into rewriting it. + expect(h.persisted, "wrote to the config while honouring a disable").toHaveLength(0) + expect(h.connects, "retried an entry the user disabled").toHaveLength(0) + }) + + test("a memoised success does not outlive the entry being disabled", async () => { + // The disable check lives in `run()`, and a settled success never re-enters + // it. Every later turn of that session is decided by the memo alone, so the + // check has to be reachable from the validation path too. + let enabled = true + const h = install({ + statuses: [ + {}, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.existingEntry = async () => + ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }) as ExistingEntry + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + + enabled = false + expect(await ensure("s1"), "rode the memo straight past the user's disable").toEqual({ kind: "entry-disabled" }) + expect(h.removes, "kept serving the disabled entry's tools for the rest of the session").toContain("datamate") + }) + + test("nothing awaits between the final binding check and the install", async () => { + // The guard is only worth what the gap after it is: any await between the + // check and the mutations reopens the window the check exists to close. The + // late guard would undo this attach — but only after it had spawned an + // engine and taken the per-project lock, which is long enough for the + // replacement attach's first-turn wait to expire. + let current: CachedBinding | null = binding + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return null + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added, "installed an engine for a workspace the project had already left").toHaveLength(0) + expect(h.persisted, "pinned a workspace the project had already left").toHaveLength(0) + }) +}) From d686c3be26f85d547fd68e873499f0130bcc1fe0 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:38:55 +0800 Subject: [PATCH 24/67] refactor(workspace): state what each outcome means, once, over the whole union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumers each need a yes/no answer about an attach outcome. Tool precedence asks whether an engine attributable to this session is serving it; the install offer asks whether installing would help. Both had derived their answer independently — one by comparing kinds at the call site, the other by relying on where its call site sat in the control flow, which made a deliberately disabled entry safe only because that branch happened to return earlier than the offer's hooks. Both are the same latent defect: adding a state to the union silently gives it an answer nobody chose. The offer case is the sharp one — unify refusal reporting, as the next commits do, and a user who deliberately switched their engine off would be offered an install for the engine they already have, with every existing test still passing. So both answers become tables keyed by the union. A new variant fails to compile until each table names it, and the safe answer is false in both, so the compiler asks and the reviewer decides. That guard holds regardless of tsconfig strictness, which an exhaustive switch with no default arm does not; adding a variant was confirmed to fail all three tables, including the test's own. No behaviour change: `wasServing` now reads its answer from the table it already encoded by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 63 ++++++++++++++++++- .../altimate/workspace/engine-sync.test.ts | 62 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index aaa4fc9b23..24ba717efd 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -974,9 +974,70 @@ function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) } +/** What each outcome MEANS, stated once, as tables over the whole union. + * + * Two different consumers — tool precedence and the install offer — each need a + * yes/no answer about an outcome, and each had derived it independently: one by + * comparing kinds inline, the other by relying on where its call site sat in the + * control flow. Both are the same latent bug, which is that adding a state to + * this union silently gives it an answer nobody chose. + * + * A `Record` keyed by the union is the strongest available guard: a new variant + * fails to compile until every table names it, and a removed one fails too. That + * holds regardless of tsconfig strictness, which a `switch` with no default does + * not. The safe answer is `false` in both tables, so the compiler asks the + * question and the reviewer answers it deliberately. */ +const SERVING: Record = { + attached: true, + reused: true, + disabled: false, + unbound: false, + "engine-missing": false, + "engine-too-old": false, + "connect-failed": false, + "entry-disabled": false, + // The binding moved while this attach was in flight, so whatever is connected + // was established for a workspace this project has already left. + superseded: false, +} + +/** Would installing the engine fix this outcome? + * + * NOT the same question as "did the attach refuse", and the two diverge exactly + * where it matters: a user who deliberately disabled their engine would be + * offered an install for an engine they already have and switched off, and a + * failed connection is not an absence. Only genuine unobtainability qualifies. */ +const INSTALL_HELPS: Record = { + "engine-missing": true, + "engine-too-old": true, + attached: false, + reused: false, + disabled: false, + unbound: false, + "connect-failed": false, + "entry-disabled": false, + superseded: false, +} + +/** Is an engine attributable to THIS session serving it? + * + * The contract for tool precedence: the config pin is the naming signal and this + * is the runtime one, and both must agree before queries are routed into a + * workspace's credentials. `undefined` means not settled — in flight or never + * attached — and must stay distinguishable from a refusal, because the caller + * fails open on it. */ +export function attributableEngine(outcome: Outcome | undefined): boolean { + return !!outcome && SERVING[outcome.kind] +} + +/** Would offering to install the engine be a remedy for this outcome? */ +export function installWouldHelp(outcome: Outcome | undefined): boolean { + return !!outcome && INSTALL_HELPS[outcome.kind] +} + /** Did this outcome leave an engine serving this session? */ function wasServing(outcome: Outcome | undefined): boolean { - return outcome?.kind === "attached" || outcome?.kind === "reused" + return attributableEngine(outcome) } /** Is the engine we attached still connected? diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 4d6af05bb9..545f4791a6 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -19,7 +19,10 @@ import { trackedSessionsForTests, trackedChainsForTests, settledOutcome, + attributableEngine, + installWouldHelp, type LocalMcpConfig, + type Outcome, } from "../../../src/altimate/workspace/engine-sync" import type { CachedBinding } from "../../../src/altimate/workspace/state" import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" @@ -1589,3 +1592,62 @@ describe("INVARIANT — a disabled entry serves nothing", () => { expect(h.persisted, "pinned a workspace the project had already left").toHaveLength(0) }) }) + +describe("INVARIANT — every outcome answers both consumer questions deliberately", () => { + // Typed by the union on purpose. Adding a state to `Outcome` fails to compile + // here until someone decides what it means for BOTH consumers — which is the + // point: the bug this guards against is not a wrong answer, it is a state + // acquiring an answer nobody chose. + const EXPECTED: Record = { + attached: { serving: true, installHelps: false }, + reused: { serving: true, installHelps: false }, + disabled: { serving: false, installHelps: false }, + unbound: { serving: false, installHelps: false }, + "engine-missing": { serving: false, installHelps: true }, + "engine-too-old": { serving: false, installHelps: true }, + "connect-failed": { serving: false, installHelps: false }, + "entry-disabled": { serving: false, installHelps: false }, + superseded: { serving: false, installHelps: false }, + } + + test("attribution and remedy are decided across the whole union, not a sample", () => { + for (const [kind, want] of Object.entries(EXPECTED)) { + const outcome = { kind } as Outcome + expect(attributableEngine(outcome), `attribution for ${kind}`).toBe(want.serving) + expect(installWouldHelp(outcome), `install remedy for ${kind}`).toBe(want.installHelps) + } + }) + + test("an unsettled attach answers neither question", () => { + // `undefined` means in-flight OR never attached. Both consumers fail open on + // it, so it must never be mistaken for a settled verdict. + expect(attributableEngine(undefined)).toBe(false) + expect(installWouldHelp(undefined)).toBe(false) + }) + + test("refusing to attach is not the same as being unable to obtain an engine", () => { + // The distinction the offer depends on: these refused, but an install fixes + // none of them — a user who switched their engine off would be offered the + // engine they already have. + expect(installWouldHelp({ kind: "entry-disabled" })).toBe(false) + expect(installWouldHelp({ kind: "connect-failed", error: "exit 1" })).toBe(false) + expect(installWouldHelp({ kind: "superseded" })).toBe(false) + // ...and these are exactly the two an install does fix. + expect(installWouldHelp({ kind: "engine-missing", declared: 0 })).toBe(true) + expect(installWouldHelp({ kind: "engine-too-old", found: "0.6.3" })).toBe(true) + }) + + test("a superseded attach is never attributed to the session that raced it", () => { + // The binding moved mid-flight, so what is connected belongs to a workspace + // this project has left. Attributing it would route queries there with its + // credentials. + expect(attributableEngine({ kind: "superseded" })).toBe(false) + }) + + test("attribution is keyed to the session, not to the last attach anywhere", async () => { + install({ statuses: [{ datamate: { status: "connected" } }], existing: null, which: null }) + await ensure("s1") + expect(settledOutcome("s1")).toBeDefined() + expect(settledOutcome("s2"), "a session that never attached inherited another's verdict").toBeUndefined() + }) +}) From 877fefd4462dc62d1b6b9758ab8d09a46543629f Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:44:37 +0800 Subject: [PATCH 25/67] refactor(workspace): decide what an existing entry means in one pure step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach flow's worst defects were never wrong checks; they were correct checks in the wrong order. Intent read after connectivity, so a config disable was invisible while the client stayed live. Config read after the status gate, so an entry added by an IDE was missing from status entirely. Attribution checked after a version probe on one path and before it on another. Each was found by a separate review round, and each was reachable only because an await sat between the two checks — a config read, a status call, a process spawn. `planForEntry` takes the whole decision synchronously over one snapshot, in one fixed order of authority: intent outranks connectivity, connectivity outranks attribution, attribution outranks version. A function that cannot await cannot reorder itself, so that class of defect stops being possible rather than being fixed again. Demoting the intent check below connectivity now fails six tests, including the three original regressions. "One retry, never two" becomes the `retried` argument rather than a branch that must not be re-entered. `clearsFloor` becomes the single definition of an unusable engine, replacing three inline comparisons — including the duplicate inside the cached-success validator, which is precisely how a describer and a decider drift apart. PATH is still probed lazily, inside the branch that asks: folding it into the pure decision would charge the reuse path a process spawn on every turn for a question it never asks. No behaviour change: every existing test passes untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 431 +++++++++++------- .../altimate/workspace/engine-sync.test.ts | 68 +++ 2 files changed, 323 insertions(+), 176 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 24ba717efd..e9739ac6e1 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -549,6 +549,93 @@ function describeMissing(missing: string[]): string { return ` Declared but not available: ${shown}${more}.` } +/** Is this engine version usable at all? + * + * The single definition of "unusable" for this module. An unreadable version is + * treated as below the floor: the floor exists because engines under it do not + * lock their `--datamate` pin, and an engine that cannot say what it is cannot + * be shown to lock it either. */ +export function clearsFloor(version: string | null): boolean { + return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 0 +} + +/** The version of the ENGINE an entry runs, not of whatever wraps it. + * + * `npx @altimateai/datamate@0.6.3 start-stdio --datamate 42` would otherwise + * have us run `npx --version` and let a pre-floor engine clear the floor on the + * wrapper's version. Asking the running server instead is not an option: + * `serverInfo.version` is a hard-coded placeholder on the very engines this + * floor excludes. An unidentifiable command yields null, which `clearsFloor` + * treats as below the floor. */ +async function engineVersionOf(entry: ExistingEntry | null): Promise { + const bin = commandArgv(entry)[0] + const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null + return direct ? await versionOf(direct) : null +} + +/** What an existing entry means for this workspace — the whole decision, taken + * in one synchronous step over one snapshot. + * + * The order below is the contract, and it is the part of this module with the + * worst history: intent outranks connectivity, connectivity outranks + * attribution, attribution outranks version. Three separate review rounds each + * found one of those checks sitting on the wrong side of another, and each time + * the defect was reachable only because an await separated them — a config read, + * a status call, a version probe. A function that cannot await cannot reorder + * itself, so those defects stop being possible rather than being fixed again. + * + * `retried` is why "one retry, never two" is a property here rather than a + * branch someone has to remember not to re-enter. */ +type EntryPlan = + | { act: "spawn" } + | { act: "honour-disable" } + | { act: "retry-connect" } + | { act: "refuse-unreachable"; error: string } + | { act: "replace-unreachable-url"; url: string } + | { act: "replace-unattributable"; entry: string; pinnedTo: string | null } + | { act: "check-version" } + +export function planForEntry( + entry: ExistingEntry | null, + observed: { status: string; error?: string } | undefined, + workspaceId: string, + retried: boolean, +): EntryPlan { + // Nothing registered under this key: there is no entry to judge. + if (!observed) return { act: "spawn" } + + // Intent first. The config's `enabled` flag is the only place a user + // expresses "off", and the two sources disagree in BOTH directions: + // `MCP.status()` synthesizes "disabled" for a configured entry with no + // runtime status (so a teardown looks like a user disable), and it keeps + // reporting "connected" from live client state after the config has been set + // to disabled (so a real disable looked like nothing at all). Gating on + // connectivity missed the second case entirely. + if (entry?.enabled === false) return { act: "honour-disable" } + + if (observed.status !== "connected") { + // A dead URL is not something this client can revive — only the IDE can + // restore its port — so it is replaced rather than retried. + if (isUrlEntry(entry)) return { act: "replace-unreachable-url", url: entry.url } + if (retried) return { act: "refuse-unreachable", error: observed.error ?? observed.status ?? "not connected" } + return { act: "retry-connect" } + } + + // Live — either it already was, or the single retry brought it back. A + // recovered entry is gated exactly like one that never dropped. + // + // "Connected" is not attribution. An entry without `--datamate ` follows + // its owner's active teammate, which changes at runtime from a UI this client + // does not control; reusing one would report "workspace X: N tools" about a + // process serving Y, and once precedence acts on that inventory it routes the + // model into another workspace's credentials. + const pin = pinnedWorkspace(entry) + if (pin !== workspaceId) { + return { act: "replace-unattributable", entry: describeEntry(entry), pinnedTo: pin } + } + return { act: "check-version" } +} + async function run(): Promise { if (!isEnabled()) return { kind: "disabled" } @@ -639,188 +726,180 @@ async function run(): Promise { // added after the cache was warmed is missing from status entirely, `existing` // is undefined, rule 1 never runs, and we persist our managed entry straight // over theirs. Refreshing first is what makes the status gate trustworthy. + // Intent, then connectivity, then attribution, then version. + // + // That order is what this flow kept getting wrong: three separate review + // rounds each moved one of these checks past another, and every one of those + // mistakes was possible only because the checks were separated by an await. + // `planForEntry` cannot await, so none of them is expressible against it. + // + // The entry is read BEFORE the status it is judged against. `existingEntry` + // refreshes the config cache that `MCP.status()` then reads, so an entry an + // IDE added after the cache warmed would otherwise be missing from status + // entirely — the entry check would never run and our managed entry would be + // persisted straight over theirs. const entry = await existingEntry(DATAMATE_KEY) - const before = await client.status() - const existing = before[DATAMATE_KEY] - if (existing) { - let connected = existing.status === "connected" - - // Intent first, connectivity second. The config's `enabled` flag is the - // only place a user expresses "off", and the two sources disagree in BOTH - // directions: `MCP.status()` synthesizes "disabled" for a configured entry - // that has no runtime status (so a teardown looks like a user disable), and - // it keeps reporting "connected" from live client state after the config - // has been set to disabled (so a real disable looked like nothing at all). - // Gating on connectivity missed the second case entirely — and for an - // unpinned entry the replacement path below would then have persisted it - // enabled again, undoing the very edit the user made. + let observed = (await client.status())[DATAMATE_KEY] + let plan = planForEntry(entry, observed, workspaceId, false) + + if (plan.act === "retry-connect") { + // Exactly one retry, then report — never a second spawn beside a failing + // one. "Never twice" is the `retried` argument rather than a branch someone + // has to remember not to re-enter. + await client.connect(DATAMATE_KEY).catch(() => undefined) + observed = (await client.status())[DATAMATE_KEY] + plan = planForEntry(entry, observed, workspaceId, true) + } + + if (plan.act === "honour-disable") { + // The user turned this entry off deliberately. Do NOT call `MCP.connect` to + // "retry" it: that persists `enabled: true` into whichever config owns the + // entry, so for a global `datamate` the first prompt in any bound project + // would silently re-enable it for every other project. // - // `existingEntry` is always fresh, so `entry` already reflects disk. - if (entry?.enabled === false) { - // The user turned this entry off deliberately. Do NOT call `MCP.connect` - // to "retry" it: that persists `enabled: true` into whichever config - // owns the entry, so for a global `datamate` the first prompt in any - // bound project would silently re-enable it for every other project. - // Say what is unavailable and leave their choice alone. - log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) - // Leaving the CONFIG alone is the whole point; leaving the RUNTIME alone is - // not. `MCP.status()` reports live client state, and `MCP.tools()` gates on - // exactly that status without consulting `enabled` — so an entry disabled - // after it connected keeps exporting its tools to `resolveTools`, and the - // user's "off" is honoured on disk while the model still holds the - // workspace's tools and credentials. `remove` is runtime-only: it closes - // the client and publishes ToolsChanged without writing config, which is - // precisely "respect the edit", not "re-apply" it. - await detachRejected({ reason: "the entry is disabled" }) - await notify({ - title: "Workspace engine is disabled", - message: - `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + - `integration tools are unavailable. Enable it to use them.`, - variant: "warning", - }) - return { kind: "entry-disabled" } - } + // Leaving the CONFIG alone is the point; leaving the RUNTIME alone is not. + // `MCP.status()` reports live client state and `MCP.tools()` gates on + // exactly that status, consulting the config only for a timeout — so an + // entry disabled after it connected keeps exporting its tools and its + // credentials to the turn. `remove` is runtime-only: it closes the client + // and publishes ToolsChanged without writing config, which is respecting + // the edit rather than re-applying it. + log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) + await detachRejected({ reason: "the entry is disabled" }) + await notify({ + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }) + return { kind: "entry-disabled" } + } + if (plan.act === "refuse-unreachable") { + await notify({ + title: "Workspace engine is not running", + message: + `The "${DATAMATE_KEY}" MCP entry for workspace "${binding.datamateName}" could not connect: ` + + `${plan.error}. Integration tools are unavailable until it does.`, + variant: "error", + }) + return { kind: "connect-failed", error: plan.error } + } - if (!connected) { - if (isUrlEntry(entry)) { - // Dead URL: nothing here can bring that process back — only the IDE can - // restore its port. Fall through to a local spawn and report it below. - replaced = entry.url - replacedNote = ` Replaced the unreachable engine URL ${entry.url} for this session.` - log.info("existing engine entry is a URL that is not reachable; will spawn locally", { - workspaceId, - url: entry.url, - error: existing.error, + if (plan.act === "replace-unreachable-url") { + // Dead URL: nothing here can bring that process back — only the IDE can + // restore its port. Fall through to a local spawn and report it below. + replaced = plan.url + replacedNote = ` Replaced the unreachable engine URL ${plan.url} for this session.` + log.info("existing engine entry is a URL that is not reachable; will spawn locally", { + workspaceId, + url: plan.url, + error: observed?.error, + }) + } + + if (plan.act === "replace-unattributable") { + // Not attributable to this workspace. Replacing it costs the other client + // nothing: a stdio entry is a per-client child process, so the IDE keeps its + // own engine and only OUR registration changes. A connected URL entry lands + // here too, which is the point — the hosted endpoint serves a different tool + // set, and rule 4 forbids adopting it. + replaced = plan.entry + replacedNote = plan.pinnedTo + ? ` Replaced an engine entry pinned to workspace ${plan.pinnedTo} for this session.` + : ` Replaced an engine entry that is not pinned to this workspace (${plan.entry}) for this session; ` + + `it serves whichever workspace its owner has active.` + log.info("existing engine entry is not attributable to this workspace; detaching", { + workspaceId, + pinnedTo: plan.pinnedTo, + entry: plan.entry, + }) + await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: plan.pinnedTo }) + } + + if (plan.act === "check-version") { + const found = await engineVersionOf(entry) + if (clearsFloor(found)) { + // Rule 5 applies to a reused engine too. A running engine that lost an + // integration — a connection deleted, a restart that dropped it — serves + // fewer tools than the workspace declares, and only the fresh attach used + // to say so. Reuse is the COMMON path, so staying silent here is where the + // gap would actually go unnoticed. + const present = engineToolKeys(await client.tools()) + const declaredKeys = await declaredBounded(workspaceId) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + if (declaredKeys && missing.length > 0) { + await notify({ + title: `Workspace "${binding.datamateName}" is missing declared tools`, + message: + `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + + describeMissing(missing), + variant: "warning", }) - } else { - // A command entry that failed: one retry, then report — never a second - // spawn beside a failing one. - await client.connect(DATAMATE_KEY).catch(() => undefined) - const retried = (await client.status())[DATAMATE_KEY] - connected = retried?.status === "connected" - if (!connected) { - const error = retried?.error ?? retried?.status ?? "not connected" - await notify({ - title: "Workspace engine is not running", - message: `The "${DATAMATE_KEY}" MCP entry for workspace "${binding.datamateName}" could not connect: ${error}. Integration tools are unavailable until it does.`, - variant: "error", - }) - return { kind: "connect-failed", error } - } } - } - - // Live — either it already was, or the single retry brought it back. A - // recovered entry is gated exactly like one that never dropped. - if (connected) { - const pin = pinnedWorkspace(entry) - if (pin !== workspaceId) { - // Not attributable to this workspace. Replacing it costs the other - // client nothing: a stdio entry is a per-client child process, so the - // IDE keeps its own engine and only OUR registration changes. A - // connected URL entry lands here too, which is the point — the hosted - // endpoint serves a different tool set, and rule 4 forbids adopting it. - replaced = describeEntry(entry) - replacedNote = pin - ? ` Replaced an engine entry pinned to workspace ${pin} for this session.` - : ` Replaced an engine entry that is not pinned to this workspace (${replaced}) for this session; it serves whichever workspace its owner has active.` - log.info("existing engine entry is not attributable to this workspace; detaching", { + // Returning `reused` ASSERTS that the connected engine serves the current + // binding — and the lookup above can have waited. Every mutation already + // revalidates; so must this, because the caller acts on the answer just as + // surely. A re-link inside that await would otherwise hand this turn the + // previous workspace's tools, and its credentials, under the new binding. + if (!(await stillCurrent())) { + // Detach, do not merely decline. The caller runs `resolveTools` whatever + // this returns, so leaving the old client registered hands that turn the + // previous workspace's tools and credentials anyway — the outcome is + // advice, the registration is what the model sees. + log.info("binding changed while reusing; detaching rather than answering for the old workspace", { workspaceId, - pinnedTo: pin, - entry: replaced, }) - await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: pin }) - } else { - // Probe the ENGINE, not whatever wraps it. `npx @altimateai/datamate@0.6.3 - // start-stdio --datamate 42` would otherwise have us run `npx --version` - // and let a pre-floor engine clear the floor on the wrapper's version. - // Asking the running server instead is not an option: `serverInfo.version` - // is a hard-coded placeholder on the very engines this floor excludes. - // An unidentifiable command yields no version, which falls through to the - // below-floor handling — replace it from PATH, or report it. - const entryBin = commandArgv(entry)[0] - const directBin = entryBin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(entryBin) ? entryBin : null - const found = directBin ? await versionOf(directBin) : null - if (found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) { - // Rule 5 applies to a reused engine too. A running engine that lost an - // integration — a connection deleted, a restart that dropped it — - // serves fewer tools than the workspace declares, and only the fresh - // attach used to say so. Reuse is the COMMON path, so staying silent - // here is where the gap would actually go unnoticed. - const present = engineToolKeys(await client.tools()) - const declaredKeys = await declaredBounded(workspaceId) - const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] - const available = present.size - if (declaredKeys && missing.length > 0) { - await notify({ - title: `Workspace "${binding.datamateName}" is missing declared tools`, - message: - `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + - describeMissing(missing), - variant: "warning", - }) - } - // Returning `reused` ASSERTS that the connected engine serves the - // current binding — and the lookup above can have waited. Every - // mutation already revalidates; so must this, because the caller acts - // on the answer just as surely. A re-link inside that await would - // otherwise hand this turn the previous workspace's tools, and its - // credentials, under the new binding. - if (!(await stillCurrent())) { - // Detach, do not merely decline. The caller runs `resolveTools` - // whatever this returns, so leaving the old client registered hands - // that turn the previous workspace's tools and credentials anyway — - // the outcome is advice, the registration is what the model sees. - log.info("binding changed while reusing; detaching rather than answering for the old workspace", { - workspaceId, - }) - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not detach the superseded engine", { err: String(err) }) - }) - return { kind: "superseded" } - } - log.info("reusing existing engine entry", { - workspaceId, - available, - version: found, - declared: declaredKeys?.keys.length, - missing, - }) - return { - kind: "reused", - available, - ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), - } - } - // Pinned to us, but below the floor or unreadable. Prefer a newer engine - // on PATH over keeping one whose pin the engine does not lock; if PATH - // cannot do better, say so rather than reuse it silently. - const onPath = which(ENGINE_BINARY) - const pathVersion = onPath ? await versionOf(onPath) : null - if (!pathVersion || compareVersions(pathVersion, MIN_ENGINE_VERSION) < 0) { - const label = found ?? "unknown" - // Rejected and irreplaceable: detach anyway. Leaving it connected would - // return "too old" while still serving the too-old engine's tools. - await detachRejected({ workspaceId, reason: "below-floor", found: label }) - await notify({ - title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", - message: describeRefusal(found, binding.datamateName), - variant: "warning", - }) - return { kind: "engine-too-old", found: label } - } - replaced = describeEntry(entry) - replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` - log.info("existing engine entry is below the version floor; detaching", { - workspaceId, - found, - pathVersion, + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the superseded engine", { err: String(err) }) }) - await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }) + return { kind: "superseded" } + } + log.info("reusing existing engine entry", { + workspaceId, + available, + version: found, + declared: declaredKeys?.keys.length, + missing, + }) + return { + kind: "reused", + available, + ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), } } + + // Pinned to us, but below the floor or unreadable. Prefer a newer engine on + // PATH over keeping one whose pin the engine does not lock; if PATH cannot + // do better, say so rather than reuse it silently. + // + // PATH is probed HERE rather than inside the plan because probing spawns a + // process: folding it into the pure decision would charge the reuse path — + // the common one, run on every turn — for a question it never asks. + const onPath = which(ENGINE_BINARY) + const pathVersion = onPath ? await versionOf(onPath) : null + if (!clearsFloor(pathVersion)) { + const label = found ?? "unknown" + // Rejected and irreplaceable: detach anyway. Leaving it connected would + // return "too old" while still serving the too-old engine's tools. + await detachRejected({ workspaceId, reason: "below-floor", found: label }) + await notify({ + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), + variant: "warning", + }) + return { kind: "engine-too-old", found: label } + } + replaced = describeEntry(entry) + replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` + log.info("existing engine entry is below the version floor; detaching", { + workspaceId, + found, + pathVersion, + }) + await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }) } // Bounded: this lookup is reporting only, but it runs BEFORE the engine is @@ -844,7 +923,7 @@ async function run(): Promise { } const found = await versionOf(bin) - if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { + if (!clearsFloor(found)) { const label = found ?? "unknown" await notify({ title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", @@ -1080,10 +1159,10 @@ async function engineStillOurs(workspaceId: string, record?: SessionAttach): Pro // next session. const command = commandArgv(entry).join(" ") if (record && record.validated === command) return true - const bin = commandArgv(entry)[0] - const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null - const found = direct ? await versionOf(direct) : null - if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { + // Same probe and same floor as the attach path, from the same helpers. This + // was duplicated here, which is how a describer and a decider drift apart. + const found = await engineVersionOf(entry) + if (!clearsFloor(found)) { log.info("cached attach no longer clears the version floor; re-attaching", { workspaceId, found }) return false } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 545f4791a6..f003db4c47 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -21,6 +21,8 @@ import { settledOutcome, attributableEngine, installWouldHelp, + planForEntry, + clearsFloor, type LocalMcpConfig, type Outcome, } from "../../../src/altimate/workspace/engine-sync" @@ -1651,3 +1653,69 @@ describe("INVARIANT — every outcome answers both consumer questions deliberate expect(settledOutcome("s2"), "a session that never attached inherited another's verdict").toBeUndefined() }) }) + +describe("INVARIANT — the entry decision is ordered by authority and cannot await", () => { + // The order is the contract: intent > connectivity > attribution > version. + // Three review rounds each found one of these checks on the wrong side of + // another, and every one of those defects was reachable only because an await + // separated them. These assert the order directly, on the function that has + // no awaits to separate anything. + const live = { status: "connected" } + const ours = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const theirs = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"], enabled: true } + const unpinned = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + + test("intent outranks connectivity — a disabled entry is honoured while its client is live", () => { + expect(planForEntry({ ...ours, enabled: false }, live, "42", false).act).toBe("honour-disable") + }) + + test("intent outranks attribution — a disabled entry is honoured even when it is not ours", () => { + expect(planForEntry({ ...theirs, enabled: false }, live, "42", false).act).toBe("honour-disable") + }) + + test("connectivity outranks attribution — an unreachable entry is retried before being judged ours", () => { + expect(planForEntry(theirs, { status: "failed", error: "exit 1" }, "42", false).act).toBe("retry-connect") + }) + + test("attribution outranks version — an entry pinned elsewhere is replaced, never probed", () => { + expect(planForEntry(theirs, live, "42", false)).toEqual({ + act: "replace-unattributable", + entry: "datamate start-stdio --datamate 9", + pinnedTo: "9", + }) + // An unpinned entry is equally unattributable: it follows its owner's active + // teammate, which this client does not control. + expect(planForEntry(unpinned, live, "42", false).act).toBe("replace-unattributable") + }) + + test("one retry, never two — the bound is an argument, not a branch", () => { + const failed = { status: "failed", error: "exit 1" } + expect(planForEntry(ours, failed, "42", false).act).toBe("retry-connect") + expect(planForEntry(ours, failed, "42", true)).toEqual({ act: "refuse-unreachable", error: "exit 1" }) + }) + + test("a dead URL is replaced rather than retried — only the IDE can restore its port", () => { + const url = { type: "remote", url: "http://localhost:7801/sse", enabled: true } + expect(planForEntry(url, { status: "failed" }, "42", false)).toEqual({ + act: "replace-unreachable-url", + url: "http://localhost:7801/sse", + }) + }) + + test("nothing registered is a spawn, and ours-and-live goes to the version check", () => { + expect(planForEntry(null, undefined, "42", false).act).toBe("spawn") + expect(planForEntry(ours, live, "42", false).act).toBe("check-version") + }) + + test("the decision is a value, not a promise — nothing can interleave inside it", () => { + const plan = planForEntry(ours, live, "42", false) as unknown as { then?: unknown } + expect(typeof plan.then).toBe("undefined") + }) + + test("an unreadable version is below the floor, because it cannot be shown to lock its pin", () => { + expect(clearsFloor(null)).toBe(false) + expect(clearsFloor("0.6.3")).toBe(false) + expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) + expect(clearsFloor("1.0.0")).toBe(true) + }) +}) From d76b298b37d994d1bd40bd5ede6a7fd76a4cb440 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:47:22 +0800 Subject: [PATCH 26/67] refactor(workspace): one exit for every refusal, one undo for every install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six branches each refused an attach, and each had to remember the same three things independently: tell the user, tear down any client left registered, and decide whether a remedy exists. Two review rounds were spent on branches that remembered only the first — the outcome is advice, but the registration is what the model sees, so declining while the client stays registered hands that turn the tools and credentials anyway. `refuse` makes all three structural. The toast is not optional, the teardown travels with the decision that rejected the engine, and remediability is asked once, of `installWouldHelp`, with the binding still in scope. That last point is the one that matters for the pending install-offer work: unifying refusals is exactly what makes "refused" and "no engine is obtainable" diverge, and a user who deliberately switched their engine off must never be offered an install for the engine they already have. `undoInstall` names the other half. The runtime registration and the project config were written together and, for two rounds, undone separately — first the config was left behind entirely, then it was restored from the merged view rather than the project file, which writes a copy of a global entry into the project as a permanent override. Undoing a write is only correct if it restores what that write replaced, and one function is what stops the next caller from remembering only one half. No behaviour change: every existing test passes untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 100 ++++++++++++------ 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e9739ac6e1..19d042b454 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -721,6 +721,50 @@ async function run(): Promise { log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) }) } + /** Abandon an install without trace. + * + * Both halves, together, because they were fixed one round apart: a supersede + * that undid only the runtime left our pin on disk, and MCP bootstrap starts + * every enabled entry — so a restart before the next attach would start the + * workspace this project had just walked away from. Naming them as one + * operation is what stops the next caller from remembering only one. + * + * `projectBefore` is the PROJECT file's own entry, not the merged view. + * Restoring the merged value writes a copy of a global entry into the project, + * which is a permanent override shadowing every later global change — undoing + * a write is only correct if it restores what that write replaced. */ + const undoInstall = async (projectBefore: ExistingEntry | null): Promise => { + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not remove the superseded engine", { err: String(err) }) + }) + await persistRestore(DATAMATE_KEY, projectBefore) + return { kind: "superseded" } + } + + /** The single exit for every refusal. + * + * Three properties that were previously spread across six branches, each of + * which had to remember all three: + * + * 1. An actionable failure is never silent — the toast is not optional. + * 2. A refusal that leaves a client registered tears it down. The caller runs + * `resolveTools` whatever this returns, so declining while the old client + * stays registered hands that turn its tools and its credentials anyway. + * The outcome is advice; the registration is what the model sees. + * 3. Whether a remedy exists is asked in ONE place, of `installWouldHelp`, + * with the binding still in scope. "Refused" and "no engine is obtainable" + * are different questions, and unifying refusals is exactly what makes them + * diverge: a user who deliberately disabled their engine must never be + * offered an install for the engine they already have and switched off. */ + const refuse = async (outcome: Outcome, toast: Toast, detach?: Record): Promise => { + if (detach) await detachRejected(detach) + await notify(toast) + if (installWouldHelp(outcome)) { + log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) + } + return outcome + } + // Read the entry BEFORE asking for status. `existingEntry` refreshes the config // cache and `MCP.status()` reads that same cache — so an entry an IDE or user // added after the cache was warmed is missing from status entirely, `existing` @@ -765,26 +809,27 @@ async function run(): Promise { // and publishes ToolsChanged without writing config, which is respecting // the edit rather than re-applying it. log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) - await detachRejected({ reason: "the entry is disabled" }) - await notify({ - title: "Workspace engine is disabled", - message: - `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + - `integration tools are unavailable. Enable it to use them.`, - variant: "warning", - }) - return { kind: "entry-disabled" } + return await refuse( + { kind: "entry-disabled" }, + { + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }, + { reason: "the entry is disabled" }, + ) } if (plan.act === "refuse-unreachable") { - await notify({ + return await refuse({ kind: "connect-failed", error: plan.error }, { title: "Workspace engine is not running", message: `The "${DATAMATE_KEY}" MCP entry for workspace "${binding.datamateName}" could not connect: ` + `${plan.error}. Integration tools are unavailable until it does.`, variant: "error", }) - return { kind: "connect-failed", error: plan.error } } if (plan.act === "replace-unreachable-url") { @@ -884,13 +929,15 @@ async function run(): Promise { const label = found ?? "unknown" // Rejected and irreplaceable: detach anyway. Leaving it connected would // return "too old" while still serving the too-old engine's tools. - await detachRejected({ workspaceId, reason: "below-floor", found: label }) - await notify({ - title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", - message: describeRefusal(found, binding.datamateName), - variant: "warning", - }) - return { kind: "engine-too-old", found: label } + return await refuse( + { kind: "engine-too-old", found: label }, + { + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), + variant: "warning", + }, + { workspaceId, reason: "below-floor", found: label }, + ) } replaced = describeEntry(entry) replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` @@ -912,25 +959,23 @@ async function run(): Promise { // Rule 2 / 3 — opportunistic use, or an offer. Never an install. const bin = which(ENGINE_BINARY) if (!bin) { - await notify({ + return await refuse({ kind: "engine-missing", declared: declaredCount }, { title: "Workspace integrations unavailable", message: `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, variant: "warning", }) - return { kind: "engine-missing", declared: declaredCount } } const found = await versionOf(bin) if (!clearsFloor(found)) { const label = found ?? "unknown" - await notify({ + return await refuse({ kind: "engine-too-old", found: label }, { title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", message: describeRefusal(found, binding.datamateName), variant: "warning", }) - return { kind: "engine-too-old", found: label } } // Spawn under the same server key the IDE uses, bound to THIS workspace. @@ -964,12 +1009,11 @@ async function run(): Promise { const after = (await client.status())[DATAMATE_KEY] if (after?.status !== "connected") { const error = after?.error ?? after?.status ?? "not connected" - await notify({ + return await refuse({ kind: "connect-failed", error }, { title: "Workspace engine failed to start", message: `Could not start ${ENGINE_BINARY} for workspace "${binding.datamateName}": ${error}. Integration tools are unavailable; not falling back to the hosted endpoint because it serves a different tool set.`, variant: "error", }) - return { kind: "connect-failed", error } } // Rule 5 — report declared-but-missing. @@ -986,13 +1030,7 @@ async function run(): Promise { // still revocable. if (!(await stillCurrent())) { log.info("binding changed before the attach could be reported; undoing what we installed", { workspaceId }) - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not remove the superseded engine", { err: String(err) }) - }) - // And the config: `persist()` committed the pin before the engine was known - // to be ours, and bootstrap starts every enabled entry. - await persistRestore(DATAMATE_KEY, projectBefore) - return { kind: "superseded" } + return await undoInstall(projectBefore) } // Ours, and staying: announce it so a turn that had already given up waiting From 307bbcafd39ae91edd51010bc0b07d02f7e08fda Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:54:26 +0800 Subject: [PATCH 27/67] refactor(workspace): split the attach module along its seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `engine-sync.ts` had grown to 1,436 lines holding five unrelated jobs. It is now 916, and the other four live where they belong: - `engine-types.ts` vocabulary and pure predicates — no I/O, no ambient state, so nothing here can be reordered against anything else - `engine-seams.ts` ambient access and the single test seam - `engine-probes.ts` everything that asks the outside world a question - `engine-config.ts` the only path to configuration, always refreshed - `engine-chain.ts` per-project serialization This commit moves code and moves nothing else. All 66 top-level blocks were verified byte-identical against the previous head, modulo the `export` keyword a cross-module reference requires — checked mechanically, not by eye. The leaf helpers in particular are untouched: version comparison, the version probe, the PATH lookup, HTTP bounding, the pin parser and the cache bounds are the same lines they were, because the machine buys nothing there and every touched line would be new surface. `syncInternals` stays one flat object rather than becoming per-module seams. It is the override surface consumers assign to, and splitting it would break them for no gain. The public API is unchanged for the same reason: callers import from `engine-sync` and should not have to learn where a symbol went. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-chain.ts | 48 ++ .../src/altimate/workspace/engine-config.ts | 93 +++ .../src/altimate/workspace/engine-probes.ts | 193 +++++ .../src/altimate/workspace/engine-seams.ts | 52 ++ .../src/altimate/workspace/engine-sync.ts | 666 ++---------------- .../src/altimate/workspace/engine-types.ts | 267 +++++++ 6 files changed, 707 insertions(+), 612 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/engine-chain.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-config.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-probes.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-seams.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-types.ts diff --git a/packages/opencode/src/altimate/workspace/engine-chain.ts b/packages/opencode/src/altimate/workspace/engine-chain.ts new file mode 100644 index 0000000000..87caa6b677 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-chain.ts @@ -0,0 +1,48 @@ +// altimate_change - new file +// +// Per-project serialization. Two attaches for the same project must not race to +// `MCP.add`, because whichever lands LAST owns the runtime client. +import { projectRoot } from "./engine-seams" + +/** In-flight attach chain per project. + * + * Per-session ordering is not enough: the MCP client is instance-wide, not per + * session, `MCP.add` is last-writer-wins, and `SessionRunState` keeps + * independent runners per session id — so two prompts in the same project + * genuinely overlap. Without this, a slower attach from one session can land + * after another session's and leave the runtime serving a workspace nobody is + * bound to, with both memos settled so no later turn repairs it. */ +export const attachChains = new Map>() + +export function projectKey(): string { + try { + return projectRoot() + } catch { + return "" + } +} + +export function serializeAttach(fn: () => Promise): Promise { + const key = projectKey() + const previous = attachChains.get(key) ?? Promise.resolve() + // Run regardless of how the previous attach ended — a failure must not wedge + // the chain for the rest of the process. + const next = previous.then(fn, fn) + const tail = next.then( + () => {}, + () => {}, + ) + attachChains.set(key, tail) + // Drop the entry once it settles, unless another attach has already queued + // behind it — otherwise every project path a long-running server opens is + // retained for the life of the process. Bounding `sessions` did not cover this. + void tail.then(() => { + if (attachChains.get(key) === tail) attachChains.delete(key) + }) + return next +} + +/** Test seam — how many project attach chains are currently retained. */ +export function trackedChainsForTests(): number { + return attachChains.size +} diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts new file mode 100644 index 0000000000..976ed17f98 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -0,0 +1,93 @@ +// altimate_change - new file +// +// The module's only path to configuration. Every read refreshes first, because +// this file has been bitten three times by a cached read after someone else's +// write, and the writers cannot be enumerated. +import { Config } from "@/config/config" +import { addMcpToConfig, readMcpEntryFromDisk, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { log, syncInternals, projectRoot } from "./engine-seams" +import type { ExistingEntry, LocalMcpConfig } from "./engine-types" + +export async function persist(name: string, cfg: LocalMcpConfig): Promise { + if (syncInternals.persist) return syncInternals.persist(name, cfg) + const configPath = await resolveConfigPath(projectRoot()) + await addMcpToConfig(name, cfg, configPath) + // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file + // write that does not touch that cache — so without this, every later + // `existingEntry()` in this process still sees the pre-write config. That is + // how a managed entry becomes unrecognisable to `isManagedEntry` later in the + // same server process, leaving a stale engine attached in an unbound project. + // The local-config write path in `config.ts` invalidates for the same reason. + await Config.invalidate().catch((err) => { + log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) + }) +} + +/** The module's ONLY path to config, and it is always fresh. + * + * `Config.get()` is cached per instance, and this module has now been bitten + * three times by reading it after someone else wrote: our own `addMcpToConfig`, + * `MCP.disconnect` writing `enabled: false`, and an IDE rewriting the entry — + * which never goes through `Config` at all. Two of those defeated a fix from an + * earlier round. + * + * Enumerating the writers is therefore not possible, so freshness is structural + * at the point of READ rather than remembered at each write site. The cost is + * real and shared: invalidating drops the per-instance cache for every other + * `Config` consumer too. That is the price of not having a fourth instance. */ +export async function freshConfig(): Promise<{ mcp?: Record }> { + if (syncInternals.freshConfig) return syncInternals.freshConfig() + await Config.invalidate().catch((err) => { + log.warn("could not refresh the config cache", { err: String(err) }) + }) + return (await Config.get()) as { mcp?: Record } +} + +/** The entry in the PROJECT config only, not the merged view. + * + * `existingEntry()` returns the merged value, which may come from global config, + * while `persist()` writes to the project file. Restoring the merged value would + * write a copy of the global entry into the project — a permanent override that + * shadows every later global update, disable or removal, from an attach that was + * meant to leave configuration untouched. */ +export async function projectEntry(): Promise { + if (syncInternals.projectEntry) return syncInternals.projectEntry() + try { + const configPath = await resolveConfigPath(projectRoot()) + return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null + } catch (err) { + log.warn("could not read the project-level engine entry", { err: String(err) }) + return null + } +} + +/** Put the config back the way we found it. + * + * `persist()` commits the pin BEFORE the engine is known to be ours, so a + * supersede after that point leaves the abandoned workspace pinned on disk — + * and MCP bootstraps every enabled entry, so a restart before the next attach + * would start the workspace we just walked away from. Removing the runtime + * client is only half of undoing an attach. */ +export async function persistRestore(name: string, previous: ExistingEntry | null): Promise { + if (syncInternals.persistRestore) return syncInternals.persistRestore(name, previous) + try { + const configPath = await resolveConfigPath(projectRoot()) + if (previous) await addMcpToConfig(name, previous as never, configPath) + else await removeMcpFromConfig(name, configPath) + await Config.invalidate().catch(() => undefined) + } catch (err) { + log.warn("could not restore the config after a superseded attach", { name, err: String(err) }) + } +} + +export async function existingEntry(name: string): Promise { + if (syncInternals.existingEntry) return syncInternals.existingEntry(name) + try { + const cfg = await freshConfig() + return cfg.mcp?.[name] ?? null + } catch (err) { + log.warn("could not read merged MCP config", { name, err: String(err) }) + return null + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts new file mode 100644 index 0000000000..7eccdaaf4a --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -0,0 +1,193 @@ +// altimate_change - new file +// +// Everything that asks the outside world a question: the binary, its version, +// MCP, the workspace allowlist, and the user-facing toast. Moved verbatim — the +// state machine buys nothing here, and every touched line is new-bug surface. +import launch from "cross-spawn" +import { which as whichBinary } from "@opencode-ai/core/util/which" +import { MCP, ToolsChanged } from "@/mcp" +import { AltimateApi } from "@/altimate/api/client" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding, type CachedBinding } from "./state" +import { log, syncInternals, currentDirectory } from "./engine-seams" +import { + commandArgv, + engineToolKeys, + type Declared, + type ExistingEntry, + type LocalMcpConfig, + type McpStatus, + type Toast, +} from "./engine-types" + +/** How long the optional allowlist lookup may delay a local spawn. */ +export const DECLARED_TIMEOUT_MS = 4_000 + +export async function resolveBinding(): Promise { + if (syncInternals.resolveBinding) return syncInternals.resolveBinding() + const directory = currentDirectory() + if (!directory) return null + try { + return await readLocalBinding(directory) + } catch (err) { + log.warn("could not resolve binding for engine attach", { err: String(err) }) + return null + } +} + +export function which(cmd: string): string | null { + return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) +} + +/** `datamate --version` — the engine inlines its real package version here, + * unlike its MCP `serverInfo`, which is a hard-coded placeholder. A version + * string proves output, not identity; it is a compatibility floor only. */ +export function versionOf(bin: string): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin) + return new Promise((resolve) => { + // cross-spawn, not execFile. An npm-installed engine on Windows is resolved + // by `which` to a `.cmd` shim (it honours PATHEXT), and Node cannot execute + // `.cmd` or `.bat` directly without a shell — the callback just errors. That + // would report "not runnable" to every bound Windows user with an ordinary + // global install, while MCP's own launcher started the same engine fine. + // This is the launcher the rest of the repo already uses for that reason. + let settled = false + const done = (value: string | null) => { + if (settled) return + settled = true + resolve(value) + } + try { + const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) + let out = "" + child.stdout?.on("data", (chunk) => { + out += String(chunk) + }) + child.on("error", () => done(null)) + child.on("close", (code) => { + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) + } catch { + done(null) + } + }) +} + +export function mcp() { + return ( + syncInternals.mcp ?? { + status: () => MCP.status() as Promise, + add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), + connect: (name: string) => MCP.connect(name), + remove: (name: string) => MCP.remove(name), + tools: () => MCP.tools() as Promise>, + } + ) +} + +export async function declared(datamateId: string): Promise { + if (syncInternals.declared) return syncInternals.declared(datamateId) + try { + if (!(await AltimateApi.isConfigured())) return null + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(datamateId), + AltimateApi.listIntegrations(), + ]) + const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id)) + const keys: string[] = [] + const extensionKeys: string[] = [] + for (const integration of workspace.integrations ?? []) { + const target = extensionIds.has(integration.id) ? extensionKeys : keys + for (const tool of integration.tools ?? []) target.push(tool.key) + } + return { keys, extensionKeys } + } catch (err) { + log.warn("could not read declared workspace integrations", { datamateId, err: String(err) }) + return null + } +} + +/** Tell the session its tool list changed. + * + * `MCP.add` stores the client but publishes nothing, so nothing downstream could + * even observe a late attach. This restores that signal. + * + * What it does NOT do, stated plainly because this module claimed otherwise for + * several revisions: it does not give tools to the invocation already running. + * That turn's tool set was passed to the model before the attach finished and + * cannot be rebuilt mid-call — the session's subscriber only logs, and the next + * `resolveTools` is what picks the tools up. So exceeding the bounded wait costs + * a turn, not a session. The event is worth publishing for traceability and for + * any subscriber that can act between turns; it is not a live refresh. */ +export async function announceToolsChanged(): Promise { + if (syncInternals.toolsChanged) return syncInternals.toolsChanged() + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(ToolsChanged, { server: DATAMATE_KEY })), + ) + } catch (err) { + log.warn("could not announce the workspace engine tool change", { err: String(err) }) + } +} + +/** The workspace allowlist, bounded. + * + * Reporting only — the attach must never wait on it. The bound was previously + * applied to the spawn path alone, leaving a reused engine awaiting it with no + * limit. Both paths go through here now, so there is one answer rather than two. + * + * The underlying request is separately abortable (the API client attaches a + * signal), so a stalled server releases its socket instead of accumulating + * pending fetches across repair retries. */ +export async function declaredBounded(workspaceId: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) + } finally { + // Racing does not cancel the loser: left running, the timer fires later and + // warns about a lookup that had already succeeded, on every normal attach. + if (timer) clearTimeout(timer) + } +} + +export async function notify(toast: Toast): Promise { + if (syncInternals.notify) return syncInternals.notify(toast) + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })), + ) + } catch (err) { + log.warn("could not show workspace engine toast", { err: String(err) }) + } +} + +/** The version of the ENGINE an entry runs, not of whatever wraps it. + * + * `npx @altimateai/datamate@0.6.3 start-stdio --datamate 42` would otherwise + * have us run `npx --version` and let a pre-floor engine clear the floor on the + * wrapper's version. Asking the running server instead is not an option: + * `serverInfo.version` is a hard-coded placeholder on the very engines this + * floor excludes. An unidentifiable command yields null, which `clearsFloor` + * treats as below the floor. */ +export async function engineVersionOf(entry: ExistingEntry | null): Promise { + const bin = commandArgv(entry)[0] + const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null + return direct ? await versionOf(direct) : null +} diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts new file mode 100644 index 0000000000..9a3ca785a0 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -0,0 +1,52 @@ +// altimate_change - new file +// +// Ambient access and the single test seam. `syncInternals` stays ONE flat object +// on purpose: it is the override surface every other module reaches for, and +// splitting it per module would break every consumer that assigns to it. +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +import { Instance } from "@/project/instance" +import { Log } from "@/altimate/util/log" +import type { CachedBinding } from "./state" +import type { Declared, ExistingEntry, LocalMcpConfig, McpStatus, Toast } from "./engine-types" + +export const log = Log.create({ service: "workspace-engine" }) + +/** Test seams. Production leaves every field unset. */ +export const syncInternals: { + resolveBinding?: () => Promise + which?: (cmd: string) => string | null + versionOf?: (bin: string) => Promise + mcp?: { + status: () => Promise + add: (name: string, cfg: LocalMcpConfig) => Promise + connect: (name: string) => Promise + remove: (name: string) => Promise + tools: () => Promise> + } + persist?: (name: string, cfg: LocalMcpConfig) => Promise + persistRestore?: (name: string, previous: ExistingEntry | null) => Promise + projectEntry?: () => Promise + /** The configured (merged) MCP entry under `name`, or null if none. */ + existingEntry?: (name: string) => Promise + freshConfig?: () => Promise<{ mcp?: Record }> + toolsChanged?: () => Promise + declared?: (datamateId: string) => Promise + notify?: (toast: Toast) => Promise +} = {} + +export function isEnabled(): boolean { + return CoreFlag.ALTIMATE_WORKSPACE +} + +export function currentDirectory(): string | null { + try { + return Instance.directory + } catch { + return null + } +} + +export function projectRoot(): string { + const wt = Instance.worktree + return wt === "/" ? Instance.directory : wt +} diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 19d042b454..68aafaf5b0 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -55,524 +55,70 @@ // delivers them. // // Gated on the workspace pilot flag; inert without a local binding. - -import launch from "cross-spawn" -import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" -import { which as whichBinary } from "@opencode-ai/core/util/which" -import { Instance } from "@/project/instance" -import { Log } from "@/altimate/util/log" -import { MCP, ToolsChanged } from "@/mcp" -import { addMcpToConfig, readMcpEntryFromDisk, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" -import { Config } from "@/config/config" -import { AltimateApi } from "@/altimate/api/client" +import { type CachedBinding } from "./state" import { DATAMATE_KEY } from "@/altimate/datamate-transport" -import { AppRuntime } from "@/effect/app-runtime" -import { EventV2Bridge } from "@/event-v2-bridge" -import { TuiEvent } from "@/server/tui-event" -import { readLocalBinding, type CachedBinding } from "./state" - -const log = Log.create({ service: "workspace-engine" }) - -/** How long the optional allowlist lookup may delay a local spawn. */ -const DECLARED_TIMEOUT_MS = 4_000 - -/** Oldest engine this client is known to work against. - * - * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings - * change cannot swap the workspace out from under a running engine. Everything - * below it can drift, which is precisely what the attribution check in rule 1 - * exists to exclude — so the floor and that check are one mechanism, not two. - * - * SEQUENCING: this must not ship before `@altimateai/datamate` 0.7.0 is on npm, - * or every bound user gets `engine-too-old` for a version they cannot install. */ -export const MIN_ENGINE_VERSION = "0.7.0" -export const INSTALL_HINT = "npm i -g @altimateai/datamate" -export const ENGINE_BINARY = "datamate" - -/** Engine tools arrive under the MCP server key as `_`. */ -const TOOL_PREFIX = `${DATAMATE_KEY}_` - -export type Outcome = - | { kind: "disabled" } - | { kind: "unbound" } - | { kind: "reused"; available: number; declared?: number; missing?: string[] } - | { kind: "attached"; available: number; declared: number; missing: string[]; replaced?: string } - | { kind: "engine-missing"; declared: number } - | { kind: "engine-too-old"; found: string } - | { kind: "connect-failed"; error: string } - | { kind: "entry-disabled" } - | { kind: "superseded" } - -export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } - -/** A configured MCP entry, in either shape it can reach us: opencode's own - * `command: string[]` argv, or the `{ command, args }` split an IDE writes and - * `datamate-transport` normalises. Read defensively — this is merged config - * written by other clients. */ -export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } - -type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } - -type McpStatus = Record - -/** Declared allowlist for a workspace, split by whether the CLI can serve it. - * Extension-type integrations are RPC into a live VS Code host and have no - * meaning on the CLI surface, so they are excluded from the reported gap. */ -export type Declared = { keys: string[]; extensionKeys: string[] } - -/** Test seams. Production leaves every field unset. */ -export const syncInternals: { - resolveBinding?: () => Promise - which?: (cmd: string) => string | null - versionOf?: (bin: string) => Promise - mcp?: { - status: () => Promise - add: (name: string, cfg: LocalMcpConfig) => Promise - connect: (name: string) => Promise - remove: (name: string) => Promise - tools: () => Promise> - } - persist?: (name: string, cfg: LocalMcpConfig) => Promise - persistRestore?: (name: string, previous: ExistingEntry | null) => Promise - projectEntry?: () => Promise - /** The configured (merged) MCP entry under `name`, or null if none. */ - existingEntry?: (name: string) => Promise - freshConfig?: () => Promise<{ mcp?: Record }> - toolsChanged?: () => Promise - declared?: (datamateId: string) => Promise - notify?: (toast: Toast) => Promise -} = {} - -export function isEnabled(): boolean { - return CoreFlag.ALTIMATE_WORKSPACE -} - -/** SemVer precedence compare. Returns <0, 0, >0. - * - * Build metadata is ignored, and a NON-numeric core component compares as older - * so unreadable `--version` output can never clear a floor. - * - * Pre-release ordering is honoured rather than stripped: `0.7.0-beta.1` is - * BELOW `0.7.0`. That matters here — the floor exists to require behaviour that - * shipped in a specific release (the locked `--datamate` pin), and a pre-release - * of that version predates it. Treating them as equal let a beta clear the floor - * and be trusted for reuse. */ -export function compareVersions(a: string, b: string): number { - /** An exact `major.minor.patch` of digits, or null. - * - * `Number.parseInt` was too permissive: it reads "7rc" as 7, so "0.7rc.0" - * compared EQUAL to a 0.7.0 floor, and a bare "1" won on major before its - * missing components were ever examined. Unreadable output must never - * authorise reuse of an engine whose pin-locking cannot be established, so - * anything not exactly three numeric parts is treated as older. */ - const parseCore = (raw: string): number[] | null => { - const parts = raw.split(".") - if (parts.length !== 3) return null - if (!parts.every((part) => /^\d+$/.test(part))) return null - return parts.map((part) => Number(part)) - } - const split = (v: string) => { - const bare = v.trim().replace(/^v/, "") - const plus = bare.indexOf("+") - const noBuild = plus >= 0 ? bare.slice(0, plus) : bare - const dash = noBuild.indexOf("-") - return { - core: parseCore(dash >= 0 ? noBuild.slice(0, dash) : noBuild), - pre: dash >= 0 ? noBuild.slice(dash + 1) : "", - } - } - const pa = split(a) - const pb = split(b) - // A core we cannot read ranks below one we can, and two unreadable ones tie. - if (!pa.core || !pb.core) return !pa.core && !pb.core ? 0 : pa.core ? 1 : -1 - for (let i = 0; i < 3; i++) { - if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] - } - // Same core: a release outranks every pre-release of it (SemVer §11.3). - if (!pa.pre && !pb.pre) return 0 - if (!pa.pre) return 1 - if (!pb.pre) return -1 - const ia = pa.pre.split(".") - const ib = pb.pre.split(".") - for (let i = 0; i < Math.max(ia.length, ib.length); i++) { - const x = ia[i] - const y = ib[i] - if (x === undefined) return -1 - if (y === undefined) return 1 - const nx = /^\d+$/.test(x) - const ny = /^\d+$/.test(y) - if (nx && ny) { - const d = Number(x) - Number(y) - if (d !== 0) return d - } else if (nx !== ny) { - return nx ? -1 : 1 - } else if (x !== y) { - return x < y ? -1 : 1 - } - } - return 0 -} - -/** Strip the server prefix from the engine tools present in the catalog. */ -export function engineToolKeys(tools: Record): Set { - const out = new Set() - for (const key of Object.keys(tools)) { - if (key.startsWith(TOOL_PREFIX)) out.add(key.slice(TOOL_PREFIX.length)) - } - return out -} +import { log, syncInternals, isEnabled } from "./engine-seams" +import { + attributableEngine, + clearsFloor, + commandArgv, + describeEntry, + describeMissing, + describeRefusal, + engineToolKeys, + installWouldHelp, + isUrlEntry, + pinnedWorkspace, + ENGINE_BINARY, + INSTALL_HINT, + MIN_ENGINE_VERSION, + type ExistingEntry, + type LocalMcpConfig, + type Outcome, + type Toast, +} from "./engine-types" +import { + declaredBounded, + engineVersionOf, + announceToolsChanged, + mcp, + notify, + resolveBinding, + versionOf, + which, +} from "./engine-probes" +import { existingEntry, persist, persistRestore, projectEntry } from "./engine-config" +import { serializeAttach, trackedChainsForTests, attachChains } from "./engine-chain" + +// The module's public surface is deliberately unchanged by the split: consumers +// import from `engine-sync` and should not have to know which file a symbol +// moved to. +export { + attributableEngine, + clearsFloor, + compareVersions, + engineToolKeys, + installWouldHelp, + pinnedWorkspace, + ENGINE_BINARY, + INSTALL_HINT, + MIN_ENGINE_VERSION, + type Declared, + type ExistingEntry, + type LocalMcpConfig, + type Outcome, +} from "./engine-types" +export { isEnabled, syncInternals } from "./engine-seams" +export { trackedChainsForTests } from "./engine-chain" // --------------------------------------------------------------------------- // Production implementations behind the seams // --------------------------------------------------------------------------- -function currentDirectory(): string | null { - try { - return Instance.directory - } catch { - return null - } -} - -function projectRoot(): string { - const wt = Instance.worktree - return wt === "/" ? Instance.directory : wt -} - -async function resolveBinding(): Promise { - if (syncInternals.resolveBinding) return syncInternals.resolveBinding() - const directory = currentDirectory() - if (!directory) return null - try { - return await readLocalBinding(directory) - } catch (err) { - log.warn("could not resolve binding for engine attach", { err: String(err) }) - return null - } -} - -function which(cmd: string): string | null { - return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) -} - -/** `datamate --version` — the engine inlines its real package version here, - * unlike its MCP `serverInfo`, which is a hard-coded placeholder. A version - * string proves output, not identity; it is a compatibility floor only. */ -function versionOf(bin: string): Promise { - if (syncInternals.versionOf) return syncInternals.versionOf(bin) - return new Promise((resolve) => { - // cross-spawn, not execFile. An npm-installed engine on Windows is resolved - // by `which` to a `.cmd` shim (it honours PATHEXT), and Node cannot execute - // `.cmd` or `.bat` directly without a shell — the callback just errors. That - // would report "not runnable" to every bound Windows user with an ordinary - // global install, while MCP's own launcher started the same engine fine. - // This is the launcher the rest of the repo already uses for that reason. - let settled = false - const done = (value: string | null) => { - if (settled) return - settled = true - resolve(value) - } - try { - const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) - let out = "" - child.stdout?.on("data", (chunk) => { - out += String(chunk) - }) - child.on("error", () => done(null)) - child.on("close", (code) => { - if (code !== 0) return done(null) - const line = out.trim().split(/\r?\n/)[0] ?? "" - done(line || null) - }) - } catch { - done(null) - } - }) -} - -function mcp() { - return ( - syncInternals.mcp ?? { - status: () => MCP.status() as Promise, - add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), - connect: (name: string) => MCP.connect(name), - remove: (name: string) => MCP.remove(name), - tools: () => MCP.tools() as Promise>, - } - ) -} - -async function persist(name: string, cfg: LocalMcpConfig): Promise { - if (syncInternals.persist) return syncInternals.persist(name, cfg) - const configPath = await resolveConfigPath(projectRoot()) - await addMcpToConfig(name, cfg, configPath) - // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file - // write that does not touch that cache — so without this, every later - // `existingEntry()` in this process still sees the pre-write config. That is - // how a managed entry becomes unrecognisable to `isManagedEntry` later in the - // same server process, leaving a stale engine attached in an unbound project. - // The local-config write path in `config.ts` invalidates for the same reason. - await Config.invalidate().catch((err) => { - log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) - }) -} - -/** The module's ONLY path to config, and it is always fresh. - * - * `Config.get()` is cached per instance, and this module has now been bitten - * three times by reading it after someone else wrote: our own `addMcpToConfig`, - * `MCP.disconnect` writing `enabled: false`, and an IDE rewriting the entry — - * which never goes through `Config` at all. Two of those defeated a fix from an - * earlier round. - * - * Enumerating the writers is therefore not possible, so freshness is structural - * at the point of READ rather than remembered at each write site. The cost is - * real and shared: invalidating drops the per-instance cache for every other - * `Config` consumer too. That is the price of not having a fourth instance. */ -async function freshConfig(): Promise<{ mcp?: Record }> { - if (syncInternals.freshConfig) return syncInternals.freshConfig() - await Config.invalidate().catch((err) => { - log.warn("could not refresh the config cache", { err: String(err) }) - }) - return (await Config.get()) as { mcp?: Record } -} - -/** The entry in the PROJECT config only, not the merged view. - * - * `existingEntry()` returns the merged value, which may come from global config, - * while `persist()` writes to the project file. Restoring the merged value would - * write a copy of the global entry into the project — a permanent override that - * shadows every later global update, disable or removal, from an attach that was - * meant to leave configuration untouched. */ -async function projectEntry(): Promise { - if (syncInternals.projectEntry) return syncInternals.projectEntry() - try { - const configPath = await resolveConfigPath(projectRoot()) - return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null - } catch (err) { - log.warn("could not read the project-level engine entry", { err: String(err) }) - return null - } -} - -/** Put the config back the way we found it. - * - * `persist()` commits the pin BEFORE the engine is known to be ours, so a - * supersede after that point leaves the abandoned workspace pinned on disk — - * and MCP bootstraps every enabled entry, so a restart before the next attach - * would start the workspace we just walked away from. Removing the runtime - * client is only half of undoing an attach. */ -async function persistRestore(name: string, previous: ExistingEntry | null): Promise { - if (syncInternals.persistRestore) return syncInternals.persistRestore(name, previous) - try { - const configPath = await resolveConfigPath(projectRoot()) - if (previous) await addMcpToConfig(name, previous as never, configPath) - else await removeMcpFromConfig(name, configPath) - await Config.invalidate().catch(() => undefined) - } catch (err) { - log.warn("could not restore the config after a superseded attach", { name, err: String(err) }) - } -} - -async function existingEntry(name: string): Promise { - if (syncInternals.existingEntry) return syncInternals.existingEntry(name) - try { - const cfg = await freshConfig() - return cfg.mcp?.[name] ?? null - } catch (err) { - log.warn("could not read merged MCP config", { name, err: String(err) }) - return null - } -} - -/** URL-based entries (`type: "remote"`, or any `url`) point at a process this - * client does not own: an IDE's in-process engine, or the hosted endpoint. */ -function isUrlEntry(entry: ExistingEntry | null): entry is ExistingEntry & { url: string } { - return !!entry && (entry.type === "remote" || typeof entry.url === "string") -} - -const PIN_FLAG = "--datamate" - -/** The entry's full argv, flattening both config shapes. */ -function commandArgv(entry: ExistingEntry | null): string[] { - if (!entry) return [] - const head = typeof entry.command === "string" ? [entry.command] : (entry.command ?? []) - return [...head, ...(entry.args ?? [])] -} - -/** Which workspace does this entry pin its engine to, if any? - * - * `--datamate ` is the whole of an engine's workspace identity: the engine - * locks it, so a settings change cannot swap it out underneath. An entry - * WITHOUT it is not neutral — it serves whichever teammate its owner currently - * has active, and that changes at runtime from a UI this client does not - * control. The extension writes exactly such an entry (`datamate start-stdio`, - * no pin), so "connected" alone never proves an engine serves this workspace. - * - * Scanned from the end because a repeated flag resolves last-wins, and both the - * `--datamate 5` and `--datamate=5` spellings are valid on the engine's CLI. */ -export function pinnedWorkspace(entry: ExistingEntry | null): string | null { - const argv = commandArgv(entry) - for (let i = argv.length - 1; i >= 0; i--) { - const arg = argv[i] - if (arg === PIN_FLAG) return argv[i + 1] ?? null - if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null - } - return null -} - -/** Short, printable identity of an entry, for saying what was replaced. */ -function describeEntry(entry: ExistingEntry | null): string { - if (isUrlEntry(entry)) return entry.url - const argv = commandArgv(entry) - return argv.length > 0 ? argv.join(" ") : "an engine entry with no command" -} - -async function declared(datamateId: string): Promise { - if (syncInternals.declared) return syncInternals.declared(datamateId) - try { - if (!(await AltimateApi.isConfigured())) return null - const [workspace, catalog] = await Promise.all([ - AltimateApi.getDatamate(datamateId), - AltimateApi.listIntegrations(), - ]) - const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id)) - const keys: string[] = [] - const extensionKeys: string[] = [] - for (const integration of workspace.integrations ?? []) { - const target = extensionIds.has(integration.id) ? extensionKeys : keys - for (const tool of integration.tools ?? []) target.push(tool.key) - } - return { keys, extensionKeys } - } catch (err) { - log.warn("could not read declared workspace integrations", { datamateId, err: String(err) }) - return null - } -} - -/** Tell the session its tool list changed. - * - * `MCP.add` stores the client but publishes nothing, so nothing downstream could - * even observe a late attach. This restores that signal. - * - * What it does NOT do, stated plainly because this module claimed otherwise for - * several revisions: it does not give tools to the invocation already running. - * That turn's tool set was passed to the model before the attach finished and - * cannot be rebuilt mid-call — the session's subscriber only logs, and the next - * `resolveTools` is what picks the tools up. So exceeding the bounded wait costs - * a turn, not a session. The event is worth publishing for traceability and for - * any subscriber that can act between turns; it is not a live refresh. */ -async function announceToolsChanged(): Promise { - if (syncInternals.toolsChanged) return syncInternals.toolsChanged() - try { - await AppRuntime.runPromise( - EventV2Bridge.Service.use((events) => events.publish(ToolsChanged, { server: DATAMATE_KEY })), - ) - } catch (err) { - log.warn("could not announce the workspace engine tool change", { err: String(err) }) - } -} - -/** The workspace allowlist, bounded. - * - * Reporting only — the attach must never wait on it. The bound was previously - * applied to the spawn path alone, leaving a reused engine awaiting it with no - * limit. Both paths go through here now, so there is one answer rather than two. - * - * The underlying request is separately abortable (the API client attaches a - * signal), so a stalled server releases its socket instead of accumulating - * pending fetches across repair retries. */ -async function declaredBounded(workspaceId: string): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - declared(workspaceId), - new Promise((resolve) => { - timer = setTimeout(() => { - log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { - workspaceId, - timeoutMs: DECLARED_TIMEOUT_MS, - }) - resolve(null) - }, DECLARED_TIMEOUT_MS) - timer.unref?.() - }), - ]) - } finally { - // Racing does not cancel the loser: left running, the timer fires later and - // warns about a lookup that had already succeeded, on every normal attach. - if (timer) clearTimeout(timer) - } -} - -async function notify(toast: Toast): Promise { - if (syncInternals.notify) return syncInternals.notify(toast) - try { - await AppRuntime.runPromise( - EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })), - ) - } catch (err) { - log.warn("could not show workspace engine toast", { err: String(err) }) - } -} - // --------------------------------------------------------------------------- // The attach flow // --------------------------------------------------------------------------- -/** Why an engine was refused, in the user's terms. - * - * "Too old" and "could not be run at all" are the same code path but very - * different problems, and conflating them sent more than one debugging session - * hunting a version mismatch that did not exist. `versionOf` reads stdout only - * and returns null when the process fails, so a null here means the binary did - * not produce a version — broken, not merely out of date. */ -function describeRefusal(found: string | null, workspaceName: string): string { - if (!found) { - return ( - `The ${ENGINE_BINARY} on PATH did not report a usable version, so it cannot be used for workspace ` + - `"${workspaceName}". It is more likely broken than out of date — try running \`${ENGINE_BINARY} --version\` ` + - `directly. Reinstall with: ${INSTALL_HINT}` - ) - } - return ( - `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + - `Update with: ${INSTALL_HINT}` - ) -} - -function describeMissing(missing: string[]): string { - if (missing.length === 0) return "" - const shown = missing.slice(0, 5).join(", ") - const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" - return ` Declared but not available: ${shown}${more}.` -} - -/** Is this engine version usable at all? - * - * The single definition of "unusable" for this module. An unreadable version is - * treated as below the floor: the floor exists because engines under it do not - * lock their `--datamate` pin, and an engine that cannot say what it is cannot - * be shown to lock it either. */ -export function clearsFloor(version: string | null): boolean { - return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 0 -} - -/** The version of the ENGINE an entry runs, not of whatever wraps it. - * - * `npx @altimateai/datamate@0.6.3 start-stdio --datamate 42` would otherwise - * have us run `npx --version` and let a pre-floor engine clear the floor on the - * wrapper's version. Asking the running server instead is not an option: - * `serverInfo.version` is a hard-coded placeholder on the very engines this - * floor excludes. An unidentifiable command yields null, which `clearsFloor` - * treats as below the floor. */ -async function engineVersionOf(entry: ExistingEntry | null): Promise { - const bin = commandArgv(entry)[0] - const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null - return direct ? await versionOf(direct) : null -} - /** What an existing entry means for this workspace — the whole decision, taken * in one synchronous step over one snapshot. * @@ -1091,67 +637,6 @@ function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE.has(outcome.kind) } -/** What each outcome MEANS, stated once, as tables over the whole union. - * - * Two different consumers — tool precedence and the install offer — each need a - * yes/no answer about an outcome, and each had derived it independently: one by - * comparing kinds inline, the other by relying on where its call site sat in the - * control flow. Both are the same latent bug, which is that adding a state to - * this union silently gives it an answer nobody chose. - * - * A `Record` keyed by the union is the strongest available guard: a new variant - * fails to compile until every table names it, and a removed one fails too. That - * holds regardless of tsconfig strictness, which a `switch` with no default does - * not. The safe answer is `false` in both tables, so the compiler asks the - * question and the reviewer answers it deliberately. */ -const SERVING: Record = { - attached: true, - reused: true, - disabled: false, - unbound: false, - "engine-missing": false, - "engine-too-old": false, - "connect-failed": false, - "entry-disabled": false, - // The binding moved while this attach was in flight, so whatever is connected - // was established for a workspace this project has already left. - superseded: false, -} - -/** Would installing the engine fix this outcome? - * - * NOT the same question as "did the attach refuse", and the two diverge exactly - * where it matters: a user who deliberately disabled their engine would be - * offered an install for an engine they already have and switched off, and a - * failed connection is not an absence. Only genuine unobtainability qualifies. */ -const INSTALL_HELPS: Record = { - "engine-missing": true, - "engine-too-old": true, - attached: false, - reused: false, - disabled: false, - unbound: false, - "connect-failed": false, - "entry-disabled": false, - superseded: false, -} - -/** Is an engine attributable to THIS session serving it? - * - * The contract for tool precedence: the config pin is the naming signal and this - * is the runtime one, and both must agree before queries are routed into a - * workspace's credentials. `undefined` means not settled — in flight or never - * attached — and must stay distinguishable from a refusal, because the caller - * fails open on it. */ -export function attributableEngine(outcome: Outcome | undefined): boolean { - return !!outcome && SERVING[outcome.kind] -} - -/** Would offering to install the engine be a remedy for this outcome? */ -export function installWouldHelp(outcome: Outcome | undefined): boolean { - return !!outcome && INSTALL_HELPS[outcome.kind] -} - /** Did this outcome leave an engine serving this session? */ function wasServing(outcome: Outcome | undefined): boolean { return attributableEngine(outcome) @@ -1339,49 +824,6 @@ export function ensure(sessionID: string): Promise { return entry.task } -/** In-flight attach chain per project. - * - * Per-session ordering is not enough: the MCP client is instance-wide, not per - * session, `MCP.add` is last-writer-wins, and `SessionRunState` keeps - * independent runners per session id — so two prompts in the same project - * genuinely overlap. Without this, a slower attach from one session can land - * after another session's and leave the runtime serving a workspace nobody is - * bound to, with both memos settled so no later turn repairs it. */ -const attachChains = new Map>() - -function projectKey(): string { - try { - return projectRoot() - } catch { - return "" - } -} - -function serializeAttach(fn: () => Promise): Promise { - const key = projectKey() - const previous = attachChains.get(key) ?? Promise.resolve() - // Run regardless of how the previous attach ended — a failure must not wedge - // the chain for the rest of the process. - const next = previous.then(fn, fn) - const tail = next.then( - () => {}, - () => {}, - ) - attachChains.set(key, tail) - // Drop the entry once it settles, unless another attach has already queued - // behind it — otherwise every project path a long-running server opens is - // retained for the life of the process. Bounding `sessions` did not cover this. - void tail.then(() => { - if (attachChains.get(key) === tail) attachChains.delete(key) - }) - return next -} - -/** Test seam — how many project attach chains are currently retained. */ -export function trackedChainsForTests(): number { - return attachChains.size -} - /** One attach, serialized against every other attach in this project, with the * outcome logged exactly once. */ function attachOnce(sessionID: string): Promise { diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts new file mode 100644 index 0000000000..9ccf18349c --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -0,0 +1,267 @@ +// altimate_change - new file +// +// Vocabulary for the workspace engine attach: the outcome union, the shapes it +// reads, and the pure predicates over them. Nothing here performs I/O or reads +// ambient state, so nothing here can be reordered against anything else. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" + +/** Oldest engine this client is known to work against. + * + * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings + * change cannot swap the workspace out from under a running engine. Everything + * below it can drift, which is precisely what the attribution check in rule 1 + * exists to exclude — so the floor and that check are one mechanism, not two. + * + * SEQUENCING: this must not ship before `@altimateai/datamate` 0.7.0 is on npm, + * or every bound user gets `engine-too-old` for a version they cannot install. */ +export const MIN_ENGINE_VERSION = "0.7.0" +export const INSTALL_HINT = "npm i -g @altimateai/datamate" +export const ENGINE_BINARY = "datamate" + +/** Engine tools arrive under the MCP server key as `_`. */ +export const TOOL_PREFIX = `${DATAMATE_KEY}_` + +export type Outcome = + | { kind: "disabled" } + | { kind: "unbound" } + | { kind: "reused"; available: number; declared?: number; missing?: string[] } + | { kind: "attached"; available: number; declared: number; missing: string[]; replaced?: string } + | { kind: "engine-missing"; declared: number } + | { kind: "engine-too-old"; found: string } + | { kind: "connect-failed"; error: string } + | { kind: "entry-disabled" } + | { kind: "superseded" } + +export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } + +/** A configured MCP entry, in either shape it can reach us: opencode's own + * `command: string[]` argv, or the `{ command, args }` split an IDE writes and + * `datamate-transport` normalises. Read defensively — this is merged config + * written by other clients. */ +export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } + +export type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } + +export type McpStatus = Record + +/** Declared allowlist for a workspace, split by whether the CLI can serve it. + * Extension-type integrations are RPC into a live VS Code host and have no + * meaning on the CLI surface, so they are excluded from the reported gap. */ +export type Declared = { keys: string[]; extensionKeys: string[] } + +/** SemVer precedence compare. Returns <0, 0, >0. + * + * Build metadata is ignored, and a NON-numeric core component compares as older + * so unreadable `--version` output can never clear a floor. + * + * Pre-release ordering is honoured rather than stripped: `0.7.0-beta.1` is + * BELOW `0.7.0`. That matters here — the floor exists to require behaviour that + * shipped in a specific release (the locked `--datamate` pin), and a pre-release + * of that version predates it. Treating them as equal let a beta clear the floor + * and be trusted for reuse. */ +export function compareVersions(a: string, b: string): number { + /** An exact `major.minor.patch` of digits, or null. + * + * `Number.parseInt` was too permissive: it reads "7rc" as 7, so "0.7rc.0" + * compared EQUAL to a 0.7.0 floor, and a bare "1" won on major before its + * missing components were ever examined. Unreadable output must never + * authorise reuse of an engine whose pin-locking cannot be established, so + * anything not exactly three numeric parts is treated as older. */ + const parseCore = (raw: string): number[] | null => { + const parts = raw.split(".") + if (parts.length !== 3) return null + if (!parts.every((part) => /^\d+$/.test(part))) return null + return parts.map((part) => Number(part)) + } + const split = (v: string) => { + const bare = v.trim().replace(/^v/, "") + const plus = bare.indexOf("+") + const noBuild = plus >= 0 ? bare.slice(0, plus) : bare + const dash = noBuild.indexOf("-") + return { + core: parseCore(dash >= 0 ? noBuild.slice(0, dash) : noBuild), + pre: dash >= 0 ? noBuild.slice(dash + 1) : "", + } + } + const pa = split(a) + const pb = split(b) + // A core we cannot read ranks below one we can, and two unreadable ones tie. + if (!pa.core || !pb.core) return !pa.core && !pb.core ? 0 : pa.core ? 1 : -1 + for (let i = 0; i < 3; i++) { + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] + } + // Same core: a release outranks every pre-release of it (SemVer §11.3). + if (!pa.pre && !pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + const ia = pa.pre.split(".") + const ib = pb.pre.split(".") + for (let i = 0; i < Math.max(ia.length, ib.length); i++) { + const x = ia[i] + const y = ib[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + const nx = /^\d+$/.test(x) + const ny = /^\d+$/.test(y) + if (nx && ny) { + const d = Number(x) - Number(y) + if (d !== 0) return d + } else if (nx !== ny) { + return nx ? -1 : 1 + } else if (x !== y) { + return x < y ? -1 : 1 + } + } + return 0 +} + +/** Strip the server prefix from the engine tools present in the catalog. */ +export function engineToolKeys(tools: Record): Set { + const out = new Set() + for (const key of Object.keys(tools)) { + if (key.startsWith(TOOL_PREFIX)) out.add(key.slice(TOOL_PREFIX.length)) + } + return out +} + +/** URL-based entries (`type: "remote"`, or any `url`) point at a process this + * client does not own: an IDE's in-process engine, or the hosted endpoint. */ +export function isUrlEntry(entry: ExistingEntry | null): entry is ExistingEntry & { url: string } { + return !!entry && (entry.type === "remote" || typeof entry.url === "string") +} + +export const PIN_FLAG = "--datamate" + +/** The entry's full argv, flattening both config shapes. */ +export function commandArgv(entry: ExistingEntry | null): string[] { + if (!entry) return [] + const head = typeof entry.command === "string" ? [entry.command] : (entry.command ?? []) + return [...head, ...(entry.args ?? [])] +} + +/** Which workspace does this entry pin its engine to, if any? + * + * `--datamate ` is the whole of an engine's workspace identity: the engine + * locks it, so a settings change cannot swap it out underneath. An entry + * WITHOUT it is not neutral — it serves whichever teammate its owner currently + * has active, and that changes at runtime from a UI this client does not + * control. The extension writes exactly such an entry (`datamate start-stdio`, + * no pin), so "connected" alone never proves an engine serves this workspace. + * + * Scanned from the end because a repeated flag resolves last-wins, and both the + * `--datamate 5` and `--datamate=5` spellings are valid on the engine's CLI. */ +export function pinnedWorkspace(entry: ExistingEntry | null): string | null { + const argv = commandArgv(entry) + for (let i = argv.length - 1; i >= 0; i--) { + const arg = argv[i] + if (arg === PIN_FLAG) return argv[i + 1] ?? null + if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null + } + return null +} + +/** Short, printable identity of an entry, for saying what was replaced. */ +export function describeEntry(entry: ExistingEntry | null): string { + if (isUrlEntry(entry)) return entry.url + const argv = commandArgv(entry) + return argv.length > 0 ? argv.join(" ") : "an engine entry with no command" +} + +/** Why an engine was refused, in the user's terms. + * + * "Too old" and "could not be run at all" are the same code path but very + * different problems, and conflating them sent more than one debugging session + * hunting a version mismatch that did not exist. `versionOf` reads stdout only + * and returns null when the process fails, so a null here means the binary did + * not produce a version — broken, not merely out of date. */ +export function describeRefusal(found: string | null, workspaceName: string): string { + if (!found) { + return ( + `The ${ENGINE_BINARY} on PATH did not report a usable version, so it cannot be used for workspace ` + + `"${workspaceName}". It is more likely broken than out of date — try running \`${ENGINE_BINARY} --version\` ` + + `directly. Reinstall with: ${INSTALL_HINT}` + ) + } + return ( + `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + + `Update with: ${INSTALL_HINT}` + ) +} + +export function describeMissing(missing: string[]): string { + if (missing.length === 0) return "" + const shown = missing.slice(0, 5).join(", ") + const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" + return ` Declared but not available: ${shown}${more}.` +} + +/** Is this engine version usable at all? + * + * The single definition of "unusable" for this module. An unreadable version is + * treated as below the floor: the floor exists because engines under it do not + * lock their `--datamate` pin, and an engine that cannot say what it is cannot + * be shown to lock it either. */ +export function clearsFloor(version: string | null): boolean { + return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 0 +} + +/** What each outcome MEANS, stated once, as tables over the whole union. + * + * Two different consumers — tool precedence and the install offer — each need a + * yes/no answer about an outcome, and each had derived it independently: one by + * comparing kinds inline, the other by relying on where its call site sat in the + * control flow. Both are the same latent bug, which is that adding a state to + * this union silently gives it an answer nobody chose. + * + * A `Record` keyed by the union is the strongest available guard: a new variant + * fails to compile until every table names it, and a removed one fails too. That + * holds regardless of tsconfig strictness, which a `switch` with no default does + * not. The safe answer is `false` in both tables, so the compiler asks the + * question and the reviewer answers it deliberately. */ +export const SERVING: Record = { + attached: true, + reused: true, + disabled: false, + unbound: false, + "engine-missing": false, + "engine-too-old": false, + "connect-failed": false, + "entry-disabled": false, + // The binding moved while this attach was in flight, so whatever is connected + // was established for a workspace this project has already left. + superseded: false, +} + +/** Would installing the engine fix this outcome? + * + * NOT the same question as "did the attach refuse", and the two diverge exactly + * where it matters: a user who deliberately disabled their engine would be + * offered an install for an engine they already have and switched off, and a + * failed connection is not an absence. Only genuine unobtainability qualifies. */ +export const INSTALL_HELPS: Record = { + "engine-missing": true, + "engine-too-old": true, + attached: false, + reused: false, + disabled: false, + unbound: false, + "connect-failed": false, + "entry-disabled": false, + superseded: false, +} + +/** Is an engine attributable to THIS session serving it? + * + * The contract for tool precedence: the config pin is the naming signal and this + * is the runtime one, and both must agree before queries are routed into a + * workspace's credentials. `undefined` means not settled — in flight or never + * attached — and must stay distinguishable from a refusal, because the caller + * fails open on it. */ +export function attributableEngine(outcome: Outcome | undefined): boolean { + return !!outcome && SERVING[outcome.kind] +} + +/** Would offering to install the engine be a remedy for this outcome? */ +export function installWouldHelp(outcome: Outcome | undefined): boolean { + return !!outcome && INSTALL_HELPS[outcome.kind] +} From 4a7bd9fc6cffd9f33ee24cebb0d9089fac884567 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 07:59:14 +0800 Subject: [PATCH 28/67] test(workspace): retire the per-fix tests their invariants now subsume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests, each retired only after proving the invariant catches the defect it was written for — reverted to the original defect shape, not to a broad revert that happens to break everything. The round-5 disabled-entry test was an exact duplicate of round 4's: same entry, same status, strictly weaker assertions. Round 4's stays. The round-16 test asserted that a config disable is honoured while the runtime still reports connected. Reintroducing that defect in its real shape — the intent check consulted only inside the not-connected branch, which is how it was actually written — now fails four invariants, including both halves of "a disabled entry serves nothing" and the two ordering properties. It asserted one thing the invariant did not, that nothing is attached over a disabled entry, so the invariant asserts that too now rather than losing it. Everything else stays. A broad revert failing an invariant is not proof of subsumption: `refuse` tearing down a rejected client fails the disabled invariant when removed wholesale, but a narrower regression — one call site passing no teardown — would not, so the below-floor detach tests keep earning their place. Subsumption is judged against the narrowest plausible regression, not the most convenient one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../altimate/workspace/engine-sync.test.ts | 34 +++---------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index f003db4c47..d66ab94b3e 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -922,16 +922,6 @@ describe("ensure — round 5", () => { expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) }) - test("a genuinely disabled entry (enabled:false in config) is still respected", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, - statuses: [{ datamate: { status: "disabled" } }], - }) - expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) - expect(h.connects).toHaveLength(0) - expect(h.persisted).toHaveLength(0) - }) - test("an unbound project does NOT tear down an entry it cannot prove it owns", async () => { // argv shape is not provenance: a hand-authored entry looks identical to ours. const h = install({ @@ -1462,25 +1452,6 @@ describe("INVARIANT — a cached success is re-probed and re-attributed", () => } }) -describe("ensure — round 16", () => { - test("a config disable is honoured even while the runtime is still connected", async () => { - // The mirror of the round-9 case. There the runtime said disabled and the - // config said enabled; here the config says disabled and the RUNTIME still - // says connected, because MCP reports live client state. Gating the disable - // check on connectivity skipped it entirely — and for an unpinned entry the - // replacement path would then persist it enabled again, undoing the disable. - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, - statuses: [{ datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }) - expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) - expect(h.added, "attached over an entry the user had disabled").toHaveLength(0) - expect(h.persisted, "re-enabled an entry the user had disabled").toHaveLength(0) - expect(h.connects).toHaveLength(0) - }) -}) - describe("ensure — round 18", () => { test("a re-link DURING cached-success validation is not answered with the old workspace", async () => { // The memoised-success path does its own awaited validation outside run(), @@ -1545,7 +1516,10 @@ describe("INVARIANT — a disabled entry serves nothing", () => { }) expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) expect(h.removes, "reported the entry disabled but left its client serving tools").toContain("datamate") - // Respecting the edit must not turn into rewriting it. + // Respecting the edit must not turn into rewriting it, and must not turn + // into attaching over it: for an unpinned entry the replacement path would + // otherwise persist it enabled again, undoing the very edit being honoured. + expect(h.added, "attached over an entry the user had disabled").toHaveLength(0) expect(h.persisted, "wrote to the config while honouring a disable").toHaveLength(0) expect(h.connects, "retried an entry the user disabled").toHaveLength(0) }) From 5fe9d8a6ab500e4afee19b122ce5b95ff4164f42 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:03:35 +0800 Subject: [PATCH 29/67] refactor(workspace): read config and runtime as one snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `planForEntry` took the entry and the status as two loose arguments, so while the order of the checks inside it was fixed, the freshness of the pair it judged was still a convention each caller had to honour. That is the property this rewrite exists to stop relying on, and it was the weakest point left in it. They now travel as one `Inspection`, produced by the one function that reads them — entry first, because `existingEntry` refreshes the config cache that `MCP.status()` then reads, and reading status first means judging an entry against a config that predates it. That is not hypothetical: it is how an entry an IDE had just added went missing from status entirely and the managed entry was persisted straight over it. One deliberate behaviour change, small and in the safe direction: the single connect retry now re-inspects both halves rather than re-reading status alone. `MCP.connect` writes `enabled: true` into whichever config owns the entry, so the config after a connect attempt is not necessarily the config before it — judging fresh runtime against stale config is the same defect in miniature. It costs one config read on a path that is already spawning a process. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 48 ++++++++++++++----- .../altimate/workspace/engine-sync.test.ts | 22 ++++----- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 68aafaf5b0..22649b0f8b 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -141,12 +141,30 @@ type EntryPlan = | { act: "replace-unattributable"; entry: string; pinnedTo: string | null } | { act: "check-version" } -export function planForEntry( - entry: ExistingEntry | null, - observed: { status: string; error?: string } | undefined, - workspaceId: string, - retried: boolean, -): EntryPlan { +export type Inspection = { + entry: ExistingEntry | null + observed: { status: string; error?: string } | undefined +} + +/** Config and runtime, read together, in the one correct order. + * + * The order is not incidental: `existingEntry` refreshes the config cache that + * `MCP.status()` then reads, so reading status first means judging this entry + * against a config that predates it — which is how an entry an IDE had just + * added went missing from status entirely and our own was persisted over it. + * + * They travel as one value because the decision needs BOTH and they must + * describe the same moment. Passing them as two arguments left it to each + * caller to pair them correctly, and "the caller remembers" is the property + * this whole rewrite is trying to stop relying on. */ +async function inspectEntry(): Promise { + const entry = await existingEntry(DATAMATE_KEY) + const observed = (await mcp().status())[DATAMATE_KEY] + return { entry, observed } +} + +export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { + const { entry, observed } = inspection // Nothing registered under this key: there is no entry to judge. if (!observed) return { act: "spawn" } @@ -328,18 +346,24 @@ async function run(): Promise { // IDE added after the cache warmed would otherwise be missing from status // entirely — the entry check would never run and our managed entry would be // persisted straight over theirs. - const entry = await existingEntry(DATAMATE_KEY) - let observed = (await client.status())[DATAMATE_KEY] - let plan = planForEntry(entry, observed, workspaceId, false) + let inspection = await inspectEntry() + let plan = planForEntry(inspection, workspaceId, false) if (plan.act === "retry-connect") { // Exactly one retry, then report — never a second spawn beside a failing // one. "Never twice" is the `retried` argument rather than a branch someone // has to remember not to re-enter. + // + // Re-inspected whole rather than re-reading status alone: `MCP.connect` + // writes `enabled: true` into whichever config owns the entry, so the + // config after a connect attempt is not necessarily the config before it. + // Judging fresh runtime against stale config is the same defect in + // miniature. await client.connect(DATAMATE_KEY).catch(() => undefined) - observed = (await client.status())[DATAMATE_KEY] - plan = planForEntry(entry, observed, workspaceId, true) + inspection = await inspectEntry() + plan = planForEntry(inspection, workspaceId, true) } + const entry = inspection.entry if (plan.act === "honour-disable") { // The user turned this entry off deliberately. Do NOT call `MCP.connect` to @@ -386,7 +410,7 @@ async function run(): Promise { log.info("existing engine entry is a URL that is not reachable; will spawn locally", { workspaceId, url: plan.url, - error: observed?.error, + error: inspection.observed?.error, }) } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index d66ab94b3e..ae052ac734 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1640,49 +1640,49 @@ describe("INVARIANT — the entry decision is ordered by authority and cannot aw const unpinned = { type: "local", command: ["datamate", "start-stdio"], enabled: true } test("intent outranks connectivity — a disabled entry is honoured while its client is live", () => { - expect(planForEntry({ ...ours, enabled: false }, live, "42", false).act).toBe("honour-disable") + expect(planForEntry({ entry: { ...ours, enabled: false }, observed: live }, "42", false).act).toBe("honour-disable") }) test("intent outranks attribution — a disabled entry is honoured even when it is not ours", () => { - expect(planForEntry({ ...theirs, enabled: false }, live, "42", false).act).toBe("honour-disable") + expect(planForEntry({ entry: { ...theirs, enabled: false }, observed: live }, "42", false).act).toBe("honour-disable") }) test("connectivity outranks attribution — an unreachable entry is retried before being judged ours", () => { - expect(planForEntry(theirs, { status: "failed", error: "exit 1" }, "42", false).act).toBe("retry-connect") + expect(planForEntry({ entry: theirs, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe("retry-connect") }) test("attribution outranks version — an entry pinned elsewhere is replaced, never probed", () => { - expect(planForEntry(theirs, live, "42", false)).toEqual({ + expect(planForEntry({ entry: theirs, observed: live }, "42", false)).toEqual({ act: "replace-unattributable", entry: "datamate start-stdio --datamate 9", pinnedTo: "9", }) // An unpinned entry is equally unattributable: it follows its owner's active // teammate, which this client does not control. - expect(planForEntry(unpinned, live, "42", false).act).toBe("replace-unattributable") + expect(planForEntry({ entry: unpinned, observed: live }, "42", false).act).toBe("replace-unattributable") }) test("one retry, never two — the bound is an argument, not a branch", () => { const failed = { status: "failed", error: "exit 1" } - expect(planForEntry(ours, failed, "42", false).act).toBe("retry-connect") - expect(planForEntry(ours, failed, "42", true)).toEqual({ act: "refuse-unreachable", error: "exit 1" }) + expect(planForEntry({ entry: ours, observed: failed }, "42", false).act).toBe("retry-connect") + expect(planForEntry({ entry: ours, observed: failed }, "42", true)).toEqual({ act: "refuse-unreachable", error: "exit 1" }) }) test("a dead URL is replaced rather than retried — only the IDE can restore its port", () => { const url = { type: "remote", url: "http://localhost:7801/sse", enabled: true } - expect(planForEntry(url, { status: "failed" }, "42", false)).toEqual({ + expect(planForEntry({ entry: url, observed: { status: "failed" } }, "42", false)).toEqual({ act: "replace-unreachable-url", url: "http://localhost:7801/sse", }) }) test("nothing registered is a spawn, and ours-and-live goes to the version check", () => { - expect(planForEntry(null, undefined, "42", false).act).toBe("spawn") - expect(planForEntry(ours, live, "42", false).act).toBe("check-version") + expect(planForEntry({ entry: null, observed: undefined }, "42", false).act).toBe("spawn") + expect(planForEntry({ entry: ours, observed: live }, "42", false).act).toBe("check-version") }) test("the decision is a value, not a promise — nothing can interleave inside it", () => { - const plan = planForEntry(ours, live, "42", false) as unknown as { then?: unknown } + const plan = planForEntry({ entry: ours, observed: live }, "42", false) as unknown as { then?: unknown } expect(typeof plan.then).toBe("undefined") }) From 2d8bea2d0c32fc799b1f8ca7f1392127e8ce9415 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:05:37 +0800 Subject: [PATCH 30/67] refactor(workspace): make the refusal message a substitution point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing this for the pending install-offer work turned up a defect that does not exist yet but was guaranteed at integration: `refuse` toasted unconditionally and then asked whether a remedy existed, so an offer dropped in beside that question would produce a dialog AND a toast saying the same thing. That is the double signal the offer work removed in the first place, and it would pass both suites — one asserts a toast fires on refusal, the other asserts an offer is raised, and neither asserts the user sees exactly one thing. The toast and the offer are alternatives, not a sequence. `announceRefusal` is a whole function for one call because it is the substitution point: the offer replaces its body rather than being appended near it, and it falls back to the same toast whenever it cannot reach a surface, so "an actionable failure is never silent" holds on both branches and neither emits twice. No behaviour change: today the branch only logs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 22649b0f8b..ac63a4b9bc 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -305,6 +305,31 @@ async function run(): Promise { return { kind: "superseded" } } + /** Tell the user about a refusal — exactly once. + * + * This is a whole function for what is currently one call because it is a + * substitution point, and the substitution is easy to get wrong in a way no + * test on either side would catch. + * + * `installWouldHelp` names the refusals an install would actually fix. Those + * belong to an install offer when one exists, and the offer owns the + * MESSAGING for them: it replaces this toast rather than joining it, and + * falls back to this same toast whenever it cannot reach a surface. So + * "an actionable failure is never silent" holds either way, and neither path + * emits twice. + * + * The toast and the offer are alternatives, not a sequence. A refusal that + * raises both is the double signal — a dialog and a toast saying the same + * thing — and it would pass a suite that asserts a toast fires alongside one + * that asserts an offer is raised, because neither asserts the user sees + * exactly one thing. Replace this function's body; do not add beside it. */ + const announceRefusal = async (outcome: Outcome, toast: Toast): Promise => { + if (installWouldHelp(outcome)) { + log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) + } + await notify(toast) + } + /** The single exit for every refusal. * * Three properties that were previously spread across six branches, each of @@ -322,10 +347,7 @@ async function run(): Promise { * offered an install for the engine they already have and switched off. */ const refuse = async (outcome: Outcome, toast: Toast, detach?: Record): Promise => { if (detach) await detachRejected(detach) - await notify(toast) - if (installWouldHelp(outcome)) { - log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) - } + await announceRefusal(outcome, toast) return outcome } From 6ea1ebb740c473a43a6465048b3730277eae3086 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:28:34 +0800 Subject: [PATCH 31/67] feat(mcp): remember what was actually spawned for each server key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP could answer "what should run under this key" but not "what IS running under it", and those are different questions whenever the config file is rewritten after a client was started: another process re-pinning a shared config, an IDE replacing an entry, a workspace re-link. `s.config` is written only by `add`, so for a client started at bootstrap it is empty and `getMcpConfig` falls back to the file — which means a caller comparing the file against its own expectations can agree with itself while the live client serves something else entirely, with nothing in-process able to tell. `State.spawned` records the entry each client was actually created from. `add` and the bootstrap loop set it, `remove` and shutdown clear it, and one read-only accessor exposes it. A key with no live client has no record — leaving one behind would tell a later caller that a torn-down server is still serving. Deliberately a NEW field rather than reusing `s.config`: overloading that would make `connect` re-spawn the bootstrap-time entry instead of the current file, a behaviour change nobody asked for. Nothing existing reads the new field, so no current behaviour changes. This is a shared primitive, so the change is kept to that one field. Both halves are covered in the MCP lifecycle suite and each fails independently when reverted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- packages/opencode/src/mcp/index.ts | 44 ++++++++++++++++ packages/opencode/test/mcp/lifecycle.test.ts | 50 +++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 4 files changed, 96 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27f2e85a14..7fd77ab2e6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -272,11 +272,29 @@ interface State { status: Record clients: Record defs: Record + // altimate_change start — what this process actually SPAWNED for each key. + // + // `config` is only written by `add`, so for a client started at bootstrap it + // is empty and `getMcpConfig` falls back to the config FILE — which is a + // different question. The file says what should run now; this says what is + // running. They diverge whenever the file is rewritten after a client was + // started: another process re-pinning a shared config, an IDE replacing the + // entry, a re-link. Without this record a caller comparing the file to its + // own expectations can agree with itself while the live client serves + // something else entirely, and nothing in-process can tell. + // + // Deliberately NOT folded into `config`: that would make `connect` re-spawn + // the bootstrap-time entry rather than the current file, which is a + // behaviour change nobody asked for. + spawned: Record + // altimate_change end } export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> + // altimate_change — what this process spawned for a key; see State.spawned + readonly spawned: (name: string) => Effect.Effect // altimate_change start — carry the original (pre-sanitize) client name so tool-source // classification works from the real name, not the flattened `_` key // (see altimate/tool-source). @@ -747,6 +765,8 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + // altimate_change — see State.spawned + spawned: {}, } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -778,6 +798,8 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + // altimate_change — bootstrap spawns too, so it records too. + s.spawned[key] = mcp watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -811,6 +833,8 @@ export const layer = Layer.effect( const clients = Object.values(s.clients) s.clients = {} s.defs = {} + // altimate_change — nothing is running any more; see State.spawned + s.spawned = {} yield* Effect.forEach( clients, (client) => @@ -889,6 +913,14 @@ export const layer = Layer.effect( return s.clients }) + // altimate_change start — what this process spawned for a key, or undefined + // when nothing of ours is running under it. Read-only; see State.spawned. + const spawned = Effect.fn("MCP.spawned")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.spawned[name] + }) + // altimate_change end + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) const result = yield* create(name, mcp) @@ -900,6 +932,8 @@ export const layer = Layer.effect( return result.status } + // altimate_change — remember what we actually spawned, not what the file says. + s.spawned[name] = mcp return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) }) @@ -951,6 +985,10 @@ export const layer = Layer.effect( yield* closeClient(s, name) delete s.clients[name] delete s.status[name] + // altimate_change — nothing is running under this key any more, so nothing + // was spawned under it. Leaving the record behind makes a later caller + // believe a torn-down engine is still serving. + delete s.spawned[name] yield* events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore) }) // altimate_change end @@ -1329,6 +1367,7 @@ export const layer = Layer.effect( return Service.of({ status, clients, + spawned, tools, prompts, resources, @@ -1379,6 +1418,11 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } +// altimate_change start — read what this process spawned for a key (see State.spawned) +export async function spawned(name: string) { + return runMcp((svc) => svc.spawned(name)) +} +// altimate_change end export async function add(name: string, mcp: ConfigMCPV1.Info) { return runMcp((svc) => svc.add(name, mcp)) } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 9d71a0db25..09639dcc67 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1236,3 +1236,53 @@ it.instance( ), { config: { mcp: {} } }, ) + +// altimate_change start — the spawn record: what this process actually launched +it.instance( + "records what it spawned, and forgets it when the client is removed", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "spawnrec" + // Nothing launched under this key yet. + expect(yield* mcp.spawned("spawnrec")).toBeUndefined() + + yield* mcp.add("spawnrec", { type: "local", command: ["echo", "one"] }) + expect((yield* mcp.spawned("spawnrec"))?.command).toEqual(["echo", "one"]) + + // Re-adding replaces the running client, so the record follows it. + yield* mcp.add("spawnrec", { type: "local", command: ["echo", "two"] }) + expect((yield* mcp.spawned("spawnrec"))?.command).toEqual(["echo", "two"]) + + // A key with no live client has nothing spawned under it. Leaving the + // record behind would tell a later caller that a torn-down engine is + // still serving. + yield* mcp.remove("spawnrec") + expect(yield* mcp.spawned("spawnrec")).toBeUndefined() + }), + ), +) + +it.instance( + "the record is what was launched, not what the config says now", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The whole reason this exists. `getMcpConfig` answers "what should run", + // falling back to the config file; this answers "what IS running". They + // diverge whenever the file is rewritten after a client was started — + // another process re-pinning a shared config, an IDE replacing the entry + // — and a caller comparing the file against its own expectations can + // agree with itself while the live client serves something else. + lastCreatedClientName = "spawnrec2" + yield* mcp.add("spawnrec2", { type: "local", command: ["echo", "launched"] }) + const launched = yield* mcp.spawned("spawnrec2") + expect(launched?.command).toEqual(["echo", "launched"]) + + // Whatever else happens to configuration, the record keeps naming the + // process that is actually up until it is torn down or replaced. + expect((yield* mcp.spawned("spawnrec2"))?.command).toEqual(["echo", "launched"]) + }), + ), +) +// altimate_change end diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 92fe3f8136..620e833693 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -124,6 +124,7 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + spawned: () => Effect.succeed(undefined), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index f1990520a8..3eb076b57f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -37,6 +37,7 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + spawned: () => Effect.succeed(undefined), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), From 3c87de2cb3ee3d67eb9e3aed348824eb3711c9dd Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:34:32 +0800 Subject: [PATCH 32/67] fix(workspace): attribution outranks connectivity, intent outranks absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ordering defects in the entry decision, both found by adversarial review of the pure function that was supposed to have settled ordering for good. Intent sat below the no-observation short-circuit. `{ "datamate": { "enabled": false } }` with no `type` is the upstream idiom for switching an entry off, and the only durable way to disable an IDE-discovered one from this config — and `isMcpConfigured` requires a `type`, so MCP omits it from status entirely. That marker therefore reached the spawn path and was overwritten with our own pinned `enabled: true`. "Intent outranks connectivity" was too weak a claim: absence of runtime is not connectivity. Connectivity sat above attribution, which wedges. An entry pinned to a workspace the project no longer holds, that is also down, was retried every turn and reported `connect-failed` every turn and never replaced, because the retry answered before the pin was ever consulted — the project stayed stuck until someone edited config by hand. Reviving an engine before asking whose it is has no defensible reading anyway: at best it spends a spawn on another client's process, at worst it revives the engine we rejected last turn so we can reject it again. Worth naming why it was wrong. The old order was an artifact, not a decision: the pin check lived inside an `if (connected)` block, and extracting the pure function carried that accident along with the intent — which made an accident look deliberate, since a reader now saw a stated order and assumed someone had chosen it. Faithful extraction is not free. The order is now intent > absence > attribution > connectivity > version, with the reason recorded beside each step rather than just the sequence. Three tests changed contract rather than expectation, and one invariant is inverted: reviving now requires an entry that is ours, an unpinned down entry is replaced without being started first, and the wedge has its own test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 66 ++++++++++++------- .../altimate/workspace/engine-sync.test.ts | 47 ++++++++++--- packages/opencode/test/mcp/lifecycle.test.ts | 13 ++-- 3 files changed, 90 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index ac63a4b9bc..4d60d539dd 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -165,38 +165,58 @@ async function inspectEntry(): Promise { export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { const { entry, observed } = inspection - // Nothing registered under this key: there is no entry to judge. - if (!observed) return { act: "spawn" } - // Intent first. The config's `enabled` flag is the only place a user - // expresses "off", and the two sources disagree in BOTH directions: - // `MCP.status()` synthesizes "disabled" for a configured entry with no - // runtime status (so a teardown looks like a user disable), and it keeps - // reporting "connected" from live client state after the config has been set - // to disabled (so a real disable looked like nothing at all). Gating on - // connectivity missed the second case entirely. + // 1. INTENT. Outranks everything, including whether anything is observed at + // all. `{ "datamate": { "enabled": false } }` with no `type` is the upstream + // idiom for switching an entry off, and the only durable way to disable an + // IDE-discovered one from this config — and `isMcpConfigured` requires a + // `type`, so MCP omits it from status entirely. Checking intent below the + // no-observation branch meant that marker reached the spawn path and was + // overwritten with our own pinned `enabled: true`. "Intent outranks + // connectivity" was too weak: absence of runtime is not connectivity. if (entry?.enabled === false) return { act: "honour-disable" } - if (observed.status !== "connected") { - // A dead URL is not something this client can revive — only the IDE can - // restore its port — so it is replaced rather than retried. - if (isUrlEntry(entry)) return { act: "replace-unreachable-url", url: entry.url } - if (retried) return { act: "refuse-unreachable", error: observed.error ?? observed.status ?? "not connected" } - return { act: "retry-connect" } - } + // 2. Nothing is registered under this key, so there is nothing to attribute + // and nothing to revive. Note this is BELOW intent and above everything else: + // a disable marker must be honoured even when it is invisible to status, but + // once intent is settled, absence really does mean there is nothing here. + if (!observed) return { act: "spawn" } - // Live — either it already was, or the single retry brought it back. A - // recovered entry is gated exactly like one that never dropped. + // 3. ATTRIBUTION, before connectivity — whose engine is this, not how is it + // doing. Nursing an engine back to health before asking whose it is has no + // defensible reading: at best it is work spent on another client's process, + // at worst it revives the very engine we rejected last turn and then rejects + // it again. It also wedges: an entry pinned elsewhere that is also DOWN was + // retried every turn and never replaced, because the retry answered before + // the pin was ever consulted, so the project sat on `connect-failed` until + // someone edited config by hand. // - // "Connected" is not attribution. An entry without `--datamate ` follows - // its owner's active teammate, which changes at runtime from a UI this client - // does not control; reusing one would report "workspace X: N tools" about a - // process serving Y, and once precedence acts on that inventory it routes the - // model into another workspace's credentials. + // The previous order — connectivity first — was an artifact rather than a + // decision: the pin check lived inside an `if (connected)` block, and + // extracting this function faithfully carried that accident along with the + // intent, which made it look deliberate. const pin = pinnedWorkspace(entry) if (pin !== workspaceId) { + // A URL entry pins nothing, so it lands here too — which is the point: + // the hosted endpoint serves a different tool set and rule 4 forbids + // adopting it. An unreachable one keeps its own message because that names + // the port the user's IDE is not serving. + if (isUrlEntry(entry) && observed.status !== "connected") { + return { act: "replace-unreachable-url", url: entry.url } + } return { act: "replace-unattributable", entry: describeEntry(entry), pinnedTo: pin } } + + // 4. CONNECTIVITY. Reached only for an entry that IS ours, which is the only + // kind worth reviving. + if (observed.status !== "connected") { + if (retried) { + return { act: "refuse-unreachable", error: observed.error ?? observed.status ?? "not connected" } + } + return { act: "retry-connect" } + } + + // 5. VERSION. return { act: "check-version" } } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index ae052ac734..b25f86c958 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -232,9 +232,11 @@ describe("ensure", () => { expect(h.toasts[0].message).toContain("not falling back to the hosted endpoint") }) - test("a down COMMAND entry is retried once, then reported — never double-spawned", async () => { + test("a down COMMAND entry that is OURS is retried once, then reported — never double-spawned", async () => { + // Reviving is for our own engine. The entry must be pinned to this + // workspace to reach the retry at all — see the wedge test below. const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "exit 1" } }], }) expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) @@ -243,6 +245,22 @@ describe("ensure", () => { expect(h.persisted).toHaveLength(0) }) + test("a down entry pinned ELSEWHERE is replaced, never revived — this is the wedge", async () => { + // With connectivity above attribution this could not clear: the retry + // answered before the pin was ever consulted, so an entry pinned to a + // workspace the project no longer holds was retried every turn, reported + // `connect-failed` every turn, and never replaced — the project sat wedged + // until someone edited config by hand. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added, "did not replace the unattributable entry").toHaveLength(1) + }) + test("a dead URL entry (IDE engine not running) is replaced by a local spawn, and the replacement is reported", async () => { const h = install({ existing: { type: "remote", url: "http://localhost:7801/sse" }, @@ -433,7 +451,10 @@ describe("ensure — attribution of a CONNECTED entry", () => { expect(h.removes).toHaveLength(0) // reuse must never tear down what it reuses }) - test("a recovered entry is gated too: retried back to life but unpinned, it is replaced", async () => { + test("a down UNPINNED entry is replaced without being revived first", async () => { + // It was previously retried back to life and only then judged unattributable + // and replaced — a spawn spent on a process we were always going to discard. + // Attribution above connectivity means we never start it. const h = install({ existing: { type: "local", command: ["datamate", "start-stdio"] }, statuses: [ @@ -444,7 +465,7 @@ describe("ensure — attribution of a CONNECTED entry", () => { tools: twoTools, }) const outcome = await ensure("s1") - expect(h.connects).toEqual(["datamate"]) // the one retry still happened + expect(h.connects, "revived an entry it was going to replace anyway").toHaveLength(0) expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio" }) }) }) @@ -853,9 +874,9 @@ describe("ensure — round 4", () => { expect(h.persisted).toHaveLength(0) }) - test("a genuinely FAILED entry is still retried once", async () => { + test("a genuinely FAILED entry that is OURS is still retried once", async () => { const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, statuses: [ { datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "exit 1" } }, @@ -1647,8 +1668,18 @@ describe("INVARIANT — the entry decision is ordered by authority and cannot aw expect(planForEntry({ entry: { ...theirs, enabled: false }, observed: live }, "42", false).act).toBe("honour-disable") }) - test("connectivity outranks attribution — an unreachable entry is retried before being judged ours", () => { - expect(planForEntry({ entry: theirs, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe("retry-connect") + test("attribution outranks connectivity — an unreachable entry is judged ours BEFORE being revived", () => { + // Whose engine is this, not how is it doing. Reviving one that is not ours + // spends a spawn on another client's process, and — with the old order — + // wedged the project on `connect-failed` forever, because the retry + // answered before the pin was consulted. + expect(planForEntry({ entry: theirs, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe( + "replace-unattributable", + ) + // Ours and down IS revived: that is what the retry is for. + expect(planForEntry({ entry: ours, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe( + "retry-connect", + ) }) test("attribution outranks version — an entry pinned elsewhere is replaced, never probed", () => { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 09639dcc67..f3e7115225 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1238,6 +1238,10 @@ it.instance( ) // altimate_change start — the spawn record: what this process actually launched +function localCommand(entry: { command?: string[] } | object | undefined): string[] | undefined { + return entry && "command" in entry ? entry.command : undefined +} + it.instance( "records what it spawned, and forgets it when the client is removed", () => @@ -1248,11 +1252,11 @@ it.instance( expect(yield* mcp.spawned("spawnrec")).toBeUndefined() yield* mcp.add("spawnrec", { type: "local", command: ["echo", "one"] }) - expect((yield* mcp.spawned("spawnrec"))?.command).toEqual(["echo", "one"]) + expect(localCommand(yield* mcp.spawned("spawnrec"))).toEqual(["echo", "one"]) // Re-adding replaces the running client, so the record follows it. yield* mcp.add("spawnrec", { type: "local", command: ["echo", "two"] }) - expect((yield* mcp.spawned("spawnrec"))?.command).toEqual(["echo", "two"]) + expect(localCommand(yield* mcp.spawned("spawnrec"))).toEqual(["echo", "two"]) // A key with no live client has nothing spawned under it. Leaving the // record behind would tell a later caller that a torn-down engine is @@ -1276,12 +1280,11 @@ it.instance( // agree with itself while the live client serves something else. lastCreatedClientName = "spawnrec2" yield* mcp.add("spawnrec2", { type: "local", command: ["echo", "launched"] }) - const launched = yield* mcp.spawned("spawnrec2") - expect(launched?.command).toEqual(["echo", "launched"]) + expect(localCommand(yield* mcp.spawned("spawnrec2"))).toEqual(["echo", "launched"]) // Whatever else happens to configuration, the record keeps naming the // process that is actually up until it is torn down or replaced. - expect((yield* mcp.spawned("spawnrec2"))?.command).toEqual(["echo", "launched"]) + expect(localCommand(yield* mcp.spawned("spawnrec2"))).toEqual(["echo", "launched"]) }), ), ) From 17529795b61e3295cd9712193c3945abde95346a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:37:31 +0800 Subject: [PATCH 33/67] fix(workspace): revive an engine with add, never with connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MCP.connect` was the wrong primitive three separate ways, and one change removes all three. It writes. `connect` persists `enabled: true` into whichever config owns the entry, so repairing a down IDE-written global entry wrote global config from a local decision — and a disable landing inside its window is destroyed on disk, unrecoverably, because every later read then says enabled and nothing repairs it. Round 4 closed the `enabled: false` half of exactly this defect; the `enabled: true` half survived in the retry. It resolves the wrong thing. `connect` starts whatever MCP holds in its own retained state rather than the entry this decision examined, so after our own teardown it could revive the engine we had just rejected — reject, revive, reject again, two boots per repair turn — or start a workspace the project had already left. And it was unguarded, the only mutation in the flow that never re-read the binding first. `add` writes no config and starts exactly what it is handed. Reviving is now the same operation as spawning, behind the same guard, which is the real gain: the retry stops being a special path with special rules. An invariant asserts the config-writing primitive is unreachable from every path that could once have reached it — down and ours, staying down, disabled while connected, pinned elsewhere, and a dead URL — and fails on all five when `connect` is put back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 25 +++++-- .../altimate/workspace/engine-sync.test.ts | 70 ++++++++++++++++++- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 4d60d539dd..d53a15cf59 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -396,12 +396,25 @@ async function run(): Promise { // one. "Never twice" is the `retried` argument rather than a branch someone // has to remember not to re-enter. // - // Re-inspected whole rather than re-reading status alone: `MCP.connect` - // writes `enabled: true` into whichever config owns the entry, so the - // config after a connect attempt is not necessarily the config before it. - // Judging fresh runtime against stale config is the same defect in - // miniature. - await client.connect(DATAMATE_KEY).catch(() => undefined) + // NOT `MCP.connect`, which is the wrong primitive three times over. It + // writes `enabled: true` into whichever config owns the entry — a global + // one for an IDE-written entry — so a disable landing in its window is + // destroyed on disk and nothing ever repairs it, because the next read says + // enabled. It resolves what to spawn from MCP's own retained state rather + // than from the entry this decision examined, so it can revive the engine + // we rejected last turn, or start a workspace we have already left. And it + // is a mutation, so it belongs behind the same guard as every other one. + // + // `add` is none of those: it writes no config and starts exactly what it is + // handed. Reviving becomes the same operation as spawning, which is the + // real win — the retry stops being a special path with special rules. + const revive: LocalMcpConfig = { type: "local", command: commandArgv(inspection.entry), enabled: true } + if (!(await stillCurrent())) return { kind: "superseded" } + await client.add(DATAMATE_KEY, revive).catch((err) => { + log.warn("could not restart the engine entry", { err: String(err), workspaceId }) + }) + // Re-inspected whole rather than re-reading status alone: the world may + // have moved in both halves while we were starting a process. inspection = await inspectEntry() plan = planForEntry(inspection, workspaceId, true) } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index b25f86c958..7c123c90ab 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -240,8 +240,11 @@ describe("ensure", () => { statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "exit 1" } }], }) expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) - expect(h.connects).toEqual(["datamate"]) - expect(h.added).toHaveLength(0) + // Revived with `add`, never `connect`: connect writes `enabled: true` into + // whichever config owns the entry, turning a local repair into a global + // config write. One restart attempt, and nothing persisted. + expect(h.connects, "used the config-writing primitive to repair").toHaveLength(0) + expect(h.added).toHaveLength(1) expect(h.persisted).toHaveLength(0) }) @@ -883,7 +886,8 @@ describe("ensure — round 4", () => { ], }) expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) - expect(h.connects).toEqual(["datamate"]) + expect(h.connects, "used the config-writing primitive to repair").toHaveLength(0) + expect(h.added).toHaveLength(1) }) test("two overlapping SESSIONS in one project never attach concurrently", async () => { @@ -1724,3 +1728,63 @@ describe("INVARIANT — the entry decision is ordered by authority and cannot aw expect(clearsFloor("1.0.0")).toBe(true) }) }) + +describe("INVARIANT — the attach flow never writes config from a repair", () => { + // `MCP.connect` persists `enabled: true` into whichever config owns the entry. + // For an IDE-written global entry that is merely down, repairing it locally + // would therefore write global config — and if a disable landed during the + // connect window, that disable is destroyed on disk with nothing to repair it, + // because every later read says enabled. Round 4 closed the `enabled: false` + // half of this; the `enabled: true` half lived on in the retry. + // + // The flow revives with `add`, which starts a process and writes nothing. This + // asserts the primitive is never reached, on every path that could reach it. + const scenarios: Array<[string, Parameters[0]]> = [ + [ + "ours and down", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + [ + "ours and down, staying down", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "x" } }], + }, + ], + [ + "disabled while connected", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }, + ], + [ + "pinned elsewhere and down", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + [ + "a dead URL entry", + { + existing: { type: "remote", url: "http://localhost:7801/sse" }, + statuses: [{ datamate: { status: "failed" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + ] + + for (const [name, opts] of scenarios) { + test(`no config-writing repair: ${name}`, async () => { + const h = install(opts) + await ensure("s1") + expect(h.connects, `${name} repaired the entry with the config-writing primitive`).toHaveLength(0) + }) + } +}) From cdd459c8c809428c481b7fe929d28c0309d249f4 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:43:58 +0800 Subject: [PATCH 34/67] fix(workspace): guard the whole world, and make every install undo itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects from the adversarial gate, all of the same family: a rule that held on the path someone was looking at and not on the others. The install region now owns its undo. Past `persist` and `add` this attach holds a pinned entry on disk and a registered client, and every exit that is not `attached` gives both back — via a commit flag, so exits nobody wrote are covered too. Three exits previously did not: the post-install `connect-failed` had no undo at all (leaving our pin on disk and the user's project entry gone, which then wedged the project, since a failing pin was retried rather than replaced); a throw from the status or tool read unwound past every undo with the engine registered and persisted; and the supersede guard's undo was correct but alone. The guard now re-reads the whole world, not half of it. It checked the binding and never the intent, while the plan was held across a version probe, a PATH probe, the allowlist and a disk read — seconds — so a disable landing anywhere in there was overwritten by our own pinned `enabled: true`. `addMcpToConfig` replaces the whole entry node, so a project-level disable was destroyed outright. Both reads sit in one function, which is the last await before any mutation. The invariant is not "no mutation on a stale binding" but "no mutation on a stale world". Everything readable moves above that guard: `persist` probes up to nine candidate config paths, and those awaits sat between the check and the write it protects — the previous round's defect, one call deeper than that round looked. Teardown splits by what it undoes. The guard exists to avoid destroying something that may belong to the NEW binding, which is true of exactly one of three reasons: a disabled entry serves nothing and an engine below the floor serves nobody correctly, whatever is bound. Gating those on the binding left a disabled or too-old client serving whenever a re-link raced the decision. Refusals revalidate before answering — round 13's rule reached two of seven answers — and return `superseded` without a toast rather than naming a workspace the project has left. Teardown stays before the announcement, which matters because the announcement is a substitution point and a body that waits on a person would hold a rejected client until they clicked. An announcement can no longer relabel a verdict: a throw there turned `entry-disabled` into `connect-failed` with a second toast, so failing to describe an outcome rewrote it. "Never silent" also means "never relabelled". And a spawn that fails because the binary is absent now reports `engine-missing`, consulting `which` rather than reading ENOENT out of a message — the platform detail was hiding the one case an install would fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 15 +- .../src/altimate/workspace/engine-seams.ts | 1 + .../src/altimate/workspace/engine-sync.ts | 243 ++++++++++++++---- 3 files changed, 201 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 976ed17f98..44467f5a62 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -9,9 +9,20 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport" import { log, syncInternals, projectRoot } from "./engine-seams" import type { ExistingEntry, LocalMcpConfig } from "./engine-types" -export async function persist(name: string, cfg: LocalMcpConfig): Promise { +/** Where this project's config lives. + * + * Exposed so a caller can resolve it BEFORE a guard rather than inside the + * write that follows one: `resolveConfigPath` probes up to nine candidate paths + * on disk, and every one of those awaits sits between the last check and the + * mutation it is supposed to protect. */ +export async function projectConfigPath(): Promise { + if (syncInternals.projectConfigPath) return syncInternals.projectConfigPath() + return resolveConfigPath(projectRoot()) +} + +export async function persist(name: string, cfg: LocalMcpConfig, configPath?: string): Promise { if (syncInternals.persist) return syncInternals.persist(name, cfg) - const configPath = await resolveConfigPath(projectRoot()) + configPath = configPath ?? (await resolveConfigPath(projectRoot())) await addMcpToConfig(name, cfg, configPath) // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file // write that does not touch that cache — so without this, every later diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 9a3ca785a0..e2c10e516f 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -24,6 +24,7 @@ export const syncInternals: { tools: () => Promise> } persist?: (name: string, cfg: LocalMcpConfig) => Promise + projectConfigPath?: () => Promise persistRestore?: (name: string, previous: ExistingEntry | null) => Promise projectEntry?: () => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index d53a15cf59..3f3dea2689 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -87,7 +87,7 @@ import { versionOf, which, } from "./engine-probes" -import { existingEntry, persist, persistRestore, projectEntry } from "./engine-config" +import { existingEntry, persist, persistRestore, projectConfigPath, projectEntry } from "./engine-config" import { serializeAttach, trackedChainsForTests, attachChains } from "./engine-chain" // The module's public surface is deliberately unchanged by the split: consumers @@ -283,6 +283,28 @@ async function run(): Promise { return !!now && String(now.datamateId) === workspaceId } + /** Is the world this decision was made in still the world we are mutating? + * + * `stillCurrent` asks only about the binding, and a mutation guarded on half + * the world is guarded on none of it: the plan is held across a version probe, + * a PATH probe, the workspace allowlist and a disk read — seconds — and a + * disable landing anywhere in there was then overwritten by our own pinned + * `enabled: true`. `addMcpToConfig` replaces the whole entry node, so a + * project-level disable is destroyed outright and a global one is shadowed by + * the override, after which the memo reads OUR entry and stands forever. + * + * Both reads live in one function so nothing can be inserted between them, and + * this is the LAST await before any mutation. The invariant is not "no + * mutation on a stale binding" but "no mutation on a stale world". */ + const worldUnchanged = async (): Promise => { + const entryNow = await existingEntry(DATAMATE_KEY).catch(() => null) + if (entryNow?.enabled === false) { + log.info("intent changed while deciding; not writing over a disable", { workspaceId }) + return false + } + return await stillCurrent() + } + /** Stop serving an entry we have judged untrustworthy for this workspace. * * Runtime-only (`MCP.remove`): closes the client and drops it from the tool @@ -296,8 +318,24 @@ async function run(): Promise { * connected, and the turn's `resolveTools` would hand the model exactly the * tools we just decided it must not have. It also closes the client `MCP.add` * would otherwise overwrite without closing, which orphans a second engine. */ - const detachRejected = async (why: Record): Promise => { - if (!(await stillCurrent())) { + const detachRejected = async (why: Record, bindingDependent = true): Promise => { + // The guard exists to stop us destroying something that may legitimately + // belong to the NEW binding. That applies to exactly one of the three + // reasons we tear down, and gating all of them on it left a disabled or a + // too-old client serving for the turn whenever a re-link raced the decision. + // + // Binding-INDEPENDENT, so never gated: + // - a disabled entry serves nothing. `enabled: false` is a property of the + // entry, not of a workspace, so no re-link makes it servable. + // - an engine below the floor serves nobody correctly. The floor is not + // workspace-specific either. + // - anything THIS attach started. It exists only because we made it, so + // leaving it is a leak whatever is bound now. + // + // Binding-DEPENDENT, and the only case the guard is for: + // - a pre-existing entry we did not create and judged unattributable. If + // the binding moved, that entry may be exactly what the new one wants. + if (bindingDependent && !(await stillCurrent())) { log.info("skipping teardown; the binding changed while this attach was deciding", { workspaceId, ...why }) return } @@ -344,10 +382,22 @@ async function run(): Promise { * that asserts an offer is raised, because neither asserts the user sees * exactly one thing. Replace this function's body; do not add beside it. */ const announceRefusal = async (outcome: Outcome, toast: Toast): Promise => { - if (installWouldHelp(outcome)) { - log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) + try { + if (installWouldHelp(outcome)) { + log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) + } + await notify(toast) + } catch (err) { + // "Never silent" has to also mean "never relabelled". A throw here reached + // the catch-all and turned a decided outcome — `entry-disabled`, say — + // into `connect-failed`, with a second toast, so a failure to DESCRIBE the + // verdict silently rewrote the verdict. + log.warn("could not announce the refusal; the outcome stands", { + workspaceId, + kind: outcome.kind, + err: String(err), + }) } - await notify(toast) } /** The single exit for every refusal. @@ -365,8 +415,28 @@ async function run(): Promise { * are different questions, and unifying refusals is exactly what makes them * diverge: a user who deliberately disabled their engine must never be * offered an install for the engine they already have and switched off. */ - const refuse = async (outcome: Outcome, toast: Toast, detach?: Record): Promise => { - if (detach) await detachRejected(detach) + const refuse = async ( + outcome: Outcome, + toast: Toast, + detach?: Record, + bindingDependent = true, + ): Promise => { + // Teardown BEFORE the announcement, and this order is load-bearing rather + // than incidental: the announcement is a substitution point, and a body that + // waits on a person would hold a rejected client connected until they + // clicked. Stop serving first, explain second. + if (detach) await detachRejected(detach, bindingDependent) + // Revalidate before answering — round 13's rule, which covered two of seven + // answers because only `reused` and `attached` applied it. A refusal is an + // answer too: a re-link during the config read produced `engine-missing` for + // the workspace the project had just left, and a toast naming it. + if (!(await stillCurrent())) { + log.info("binding changed before this refusal could be reported; not answering for the old workspace", { + workspaceId, + kind: outcome.kind, + }) + return { kind: "superseded" } + } await announceRefusal(outcome, toast) return outcome } @@ -444,6 +514,7 @@ async function run(): Promise { variant: "warning", }, { reason: "the entry is disabled" }, + false, ) } @@ -562,6 +633,7 @@ async function run(): Promise { variant: "warning", }, { workspaceId, reason: "below-floor", found: label }, + false, ) } replaced = describeEntry(entry) @@ -620,60 +692,119 @@ async function run(): Promise { // enough for the replacement's first-turn wait to expire, which is the failure // the guard was added to prevent. Nothing may await between the guard and the // mutations it guards. + // Everything readable is read HERE, above the guard. `persist` otherwise + // probes up to nine candidate config paths on disk between the check and the + // write it protects — round 19's defect one call deeper than round 19 looked. const projectBefore = await projectEntry() - if (!(await stillCurrent())) { - // Re-linked while we were probing. Installing now would attach the workspace - // this session has already left, and would win by arriving first. - log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) + const configPath = await projectConfigPath().catch(() => undefined) + if (!(await worldUnchanged())) { + // Re-linked or disabled while we were probing. Installing now would attach a + // workspace this session has left, or overwrite a disable that landed while + // we were deciding — and would win by arriving first. + log.info("abandoning attach; the world changed before the engine was installed", { workspaceId }) return { kind: "superseded" } } - await persist(DATAMATE_KEY, cfg) - await client.add(DATAMATE_KEY, cfg) - - // Rule 4 — a failed local engine is reported, never routed around. - const after = (await client.status())[DATAMATE_KEY] - if (after?.status !== "connected") { - const error = after?.error ?? after?.status ?? "not connected" - return await refuse({ kind: "connect-failed", error }, { - title: "Workspace engine failed to start", - message: `Could not start ${ENGINE_BINARY} for workspace "${binding.datamateName}": ${error}. Integration tools are unavailable; not falling back to the hosted endpoint because it serves a different tool set.`, - variant: "error", - }) - } - // Rule 5 — report declared-but-missing. - const present = engineToolKeys(await client.tools()) - const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] - const available = present.size - // ONE guard, placed after every await that follows the install — the handshake - // AND the tool listing. Both are windows in which a re-link can land, and the - // earlier version guarded only the first, so a flip during the tool read left - // the previous workspace installed and reported as attached. + // ---- the install region ------------------------------------------------ // - // Late rather than early on purpose: the check is only meaningful at the last - // moment before we announce and answer, because everything before that is - // still revocable. - if (!(await stillCurrent())) { - log.info("binding changed before the attach could be reported; undoing what we installed", { workspaceId }) - return await undoInstall(projectBefore) - } + // Past the next two lines this attach OWNS two things: a pinned entry on disk + // and a registered runtime client. Every exit that is not `attached` has to + // give both back — including an exit nobody wrote. + // + // Three separate defects lived in this region because each exit remembered + // the undo separately. The post-install `connect-failed` return had no undo + // at all, so a failing engine left our pin on disk and the user's own project + // entry gone; because a failing pin is retried rather than replaced, the + // project then wedged on `connect-failed` until someone edited config by + // hand. A throw from the status or tool read — a malformed config written + // concurrently by an IDE is enough — unwound straight past every undo with + // the engine registered, connected and persisted. And the supersede guard's + // undo was correct but was the only one. + // + // `committed` rather than a bare `finally` because the attached path must not + // undo itself. One rule, one place, and exits nobody anticipated are covered + // by construction rather than by review. + let committed = false + try { + await persist(DATAMATE_KEY, cfg, configPath) + await client.add(DATAMATE_KEY, cfg) - // Ours, and staying: announce it so a turn that had already given up waiting - // still learns the tools arrived. - await announceToolsChanged() - - await notify({ - title: `Workspace "${binding.datamateName}" connected`, - message: - (declaredKeys - ? `${available} of ${declaredCount} declared integration tools available.` - : `${available} integration tools available.`) + - describeMissing(missing) + - replacedNote, - variant: missing.length > 0 ? "warning" : "success", - }) - log.info("attached workspace engine", { workspaceId, available, declared: declaredCount, missing, replaced }) - return { kind: "attached", available, declared: declaredCount, missing, ...(replaced ? { replaced } : {}) } + // Rule 4 — a failed local engine is reported, never routed around. + const after = (await client.status())[DATAMATE_KEY] + if (after?.status !== "connected") { + const error = after?.error ?? after?.status ?? "not connected" + // `which` rather than the error string: "the engine failed to start" and + // "there is no engine" are different situations with different remedies, + // and only the second is fixed by installing one. Reading ENOENT out of a + // message would be re-deriving from a platform detail what a PATH lookup + // answers directly. + if (!which(ENGINE_BINARY)) { + return await refuse({ kind: "engine-missing", declared: declaredCount }, { + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + } + return await refuse({ kind: "connect-failed", error }, { + title: "Workspace engine failed to start", + message: `Could not start ${ENGINE_BINARY} for workspace "${binding.datamateName}": ${error}. Integration tools are unavailable; not falling back to the hosted endpoint because it serves a different tool set.`, + variant: "error", + }) + } + + // Rule 5 — report declared-but-missing. + const present = engineToolKeys(await client.tools()) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + // ONE guard, placed after every await that follows the install — the + // handshake AND the tool listing. Both are windows in which a re-link can + // land, and an earlier version guarded only the first, so a flip during the + // tool read left the previous workspace installed and reported as attached. + // + // Late rather than early on purpose: the check is only meaningful at the + // last moment before we announce and answer, because everything before that + // is still revocable. The undo itself now belongs to the region. + if (!(await worldUnchanged())) { + log.info("the world changed before the attach could be reported; undoing what we installed", { workspaceId }) + return { kind: "superseded" } + } + + // Ours, and staying. Answer BEFORE announcing: `announceToolsChanged` and + // the toast are two more awaits, and the outcome asserts which workspace is + // served — round 13's rule, which the announces quietly put back at risk. + committed = true + const outcome: Outcome = { + kind: "attached", + available, + declared: declaredCount, + missing, + ...(replaced ? { replaced } : {}), + } + log.info("attached workspace engine", { workspaceId, available, declared: declaredCount, missing, replaced }) + + // Announce it so a turn that had already given up waiting still learns the + // tools arrived. + await announceToolsChanged() + await notify({ + title: `Workspace "${binding.datamateName}" connected`, + message: + (declaredKeys + ? `${available} of ${declaredCount} declared integration tools available.` + : `${available} integration tools available.`) + + describeMissing(missing) + + replacedNote, + variant: missing.length > 0 ? "warning" : "success", + }) + return outcome + } finally { + if (!committed) { + await undoInstall(projectBefore).catch((err) => { + log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) + }) + } + } } // --------------------------------------------------------------------------- From 6234e9236d5551218190d5b3919dce0a4083a5aa Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:49:22 +0800 Subject: [PATCH 35/67] fix(workspace): attribution asks the running engine, and the memo asks the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same defect: this module could agree with itself while the live engine served somewhere else. Attribution judged the CONFIG entry, but the running client is whatever MCP started — which is the config file only until someone rewrites it. Three writers make them diverge: the manager tool adding a global-scope entry, the IDE's reload rewriting it with an unpinned command, and another process re-pinning a shared config file. In each case the entry named this workspace, the binding named this workspace, every check agreed, and the tools and the credentials belonged to another one. Nothing in-process could tell, because nothing in-process recorded what had been spawned; now MCP does, and the Inspection carries it. Both halves must name this workspace to earn a reuse. An absent record is not a mismatch: a key with no live client has no record, and the config is then the only evidence there is. The memo path was a second implementation of the same decision. It read status before config — the reverse of what the reader it bypassed documents — and ran its own copy of the intent, pin and floor checks, in a different order, on the path taken by every turn after the first. A second implementation of a decision is a second place for it to be wrong, and it was: it never consulted intent at all, which is how a memo outlived a disable for the life of a session. It now inspects with the same reader and decides with the same function, and "still valid" means the plan says reuse. Nothing else defines reuse any more. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-probes.ts | 1 + .../src/altimate/workspace/engine-seams.ts | 1 + .../src/altimate/workspace/engine-sync.ts | 93 +++++++++++-------- .../altimate/workspace/engine-sync.test.ts | 59 ++++++++++++ 4 files changed, 115 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 7eccdaaf4a..e94fa26b61 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -85,6 +85,7 @@ export function mcp() { add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), connect: (name: string) => MCP.connect(name), remove: (name: string) => MCP.remove(name), + spawned: (name: string) => MCP.spawned(name) as Promise, tools: () => MCP.tools() as Promise>, } ) diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index e2c10e516f..d42ca119a9 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -21,6 +21,7 @@ export const syncInternals: { add: (name: string, cfg: LocalMcpConfig) => Promise connect: (name: string) => Promise remove: (name: string) => Promise + spawned?: (name: string) => Promise tools: () => Promise> } persist?: (name: string, cfg: LocalMcpConfig) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 3f3dea2689..d18d509950 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -144,6 +144,15 @@ type EntryPlan = export type Inspection = { entry: ExistingEntry | null observed: { status: string; error?: string } | undefined + /** What MCP actually spawned under this key, when it knows. + * + * The config says what SHOULD run; this says what IS running, and they + * diverge whenever the file is rewritten after a client started — another + * process re-pinning a shared config, an IDE replacing the entry through + * `MCP.add`, a re-link. Judging attribution on the config alone let this + * module agree with itself while the live client served another workspace's + * data under this workspace's name. */ + runtime?: ExistingEntry | undefined } /** Config and runtime, read together, in the one correct order. @@ -159,8 +168,10 @@ export type Inspection = { * this whole rewrite is trying to stop relying on. */ async function inspectEntry(): Promise { const entry = await existingEntry(DATAMATE_KEY) - const observed = (await mcp().status())[DATAMATE_KEY] - return { entry, observed } + const client = mcp() + const observed = (await client.status())[DATAMATE_KEY] + const runtime = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + return { entry, observed, runtime } } export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { @@ -196,6 +207,18 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie // extracting this function faithfully carried that accident along with the // intent, which made it look deliberate. const pin = pinnedWorkspace(entry) + // Attribution is a claim about the RUNNING engine, so the running engine gets + // a vote. A config entry that names this workspace while MCP is serving a + // process started from a different one is the silent case: every check agrees, + // and the tools, and the credentials, belong to somewhere else. + const runtimePin = inspection.runtime ? pinnedWorkspace(inspection.runtime) : null + if (inspection.runtime && runtimePin !== workspaceId) { + return { + act: "replace-unattributable", + entry: describeEntry(inspection.runtime), + pinnedTo: runtimePin, + } + } if (pin !== workspaceId) { // A URL entry pins nothing, so it lands here too — which is the point: // the hosted endpoint serves a different tool set and rule 4 forbids @@ -852,49 +875,41 @@ function wasServing(outcome: Outcome | undefined): boolean { return attributableEngine(outcome) } -/** Is the engine we attached still connected? +/** Is the memoised success still true? + * + * Validated by the SAME reader and the SAME decision as a fresh attach, because + * this was a second copy of the intent/attribution/floor logic in a different + * order — it read status before config, the reverse of what the reader + * documents — and it is the common path, taken on every turn after the first. + * A second implementation of a decision is a second place for the decision to + * be wrong, and this one was: it never consulted intent at all, so a memo + * outlived a disable for the life of the session. * - * A memoised success is only true while it stays true. When the engine's child - * exits, MCP drops the client and marks the entry `failed`, but a settled - * successful outcome was returned before `run()` ever read that status — so - * every later turn resolved without the integration tools and nothing - * reconnected until a new session or a re-link. + * "Still valid" is defined as the plan saying reuse. Nothing else. * - * Fails OPEN: a status read that throws must not invalidate a good attach. */ -async function engineStillOurs(workspaceId: string, record?: SessionAttach): Promise { + * Fails OPEN: a read that throws must not invalidate a good attach. */ +async function memoStillValid(workspaceId: string, record?: SessionAttach): Promise { try { - if ((await mcp().status())[DATAMATE_KEY]?.status !== "connected") return false - // Connected is not enough. Link A -> B -> A with another session attaching B - // in between, and this session's key matches its original memo while the - // instance-wide client is serving B — so the cached success would expose B's - // tools under binding A. The pin is what makes it ours. - const entry = await existingEntry(DATAMATE_KEY) - // Intent outranks every other check, and it is checked FIRST because the - // command-unchanged shortcut below returns early: a session that already - // attached would otherwise ride its memo straight past the disable for the - // rest of its life, never re-entering `run()` where the check lives. - // Returning false here does not itself detach — it routes this session back - // through `run()`, which reports `entry-disabled` and tears the client down. - if (entry?.enabled === false) { - log.info("engine entry was disabled since the cached attach; re-deciding", { workspaceId }) + const inspection = await inspectEntry() + // `retried: true` — this is not the place to revive anything. If the engine + // is down, the memo is not valid and a fresh attach decides what to do. + const plan = planForEntry(inspection, workspaceId, true) + if (plan.act !== "check-version") { + log.info("cached attach no longer describes a reusable engine; re-deciding", { + workspaceId, + act: plan.act, + }) return false } - if (pinnedWorkspace(entry) !== workspaceId) return false - // The pin is not the whole contract: the FLOOR is what makes the pin - // trustworthy, since engines below it do not lock it. An entry reconnected - // or replaced behind the same pin with a pre-floor binary would otherwise - // ride the cached success forever, never passing through `run()` again. - // - // Re-probed only when the command CHANGES, because probing spawns a process - // and this runs every turn. The residual is narrow and worth naming: a - // binary swapped in place under an unchanged command is not caught until the - // next session. - const command = commandArgv(entry).join(" ") + // The FLOOR is what makes the pin trustworthy, since engines below it do not + // lock it. Re-probed only when the command CHANGES, because probing spawns a + // process and this runs every turn. The residual is narrow and worth naming: + // a binary swapped in place under an unchanged command is not caught until + // the next session. + const command = commandArgv(inspection.entry).join(" ") if (record && record.validated === command) return true - // Same probe and same floor as the attach path, from the same helpers. This - // was duplicated here, which is how a describer and a decider drift apart. - const found = await engineVersionOf(entry) + const found = await engineVersionOf(inspection.entry) if (!clearsFloor(found)) { log.info("cached attach no longer clears the version floor; re-attaching", { workspaceId, found }) return false @@ -995,7 +1010,7 @@ export function ensure(sessionID: string): Promise { // Re-probe before trusting a cached success — see `engineStillConnected`. const boundTo = await attachKeyWorkspace() const reusable = - !wasServing(previous!.outcome) || !boundTo || (await engineStillOurs(boundTo, entry)) + !wasServing(previous!.outcome) || !boundTo || (await memoStillValid(boundTo, entry)) // Validating the cached success is itself awaited work — status, config and // possibly a version probe — so the binding can move underneath it. This // path lives outside `run()` and therefore never had its final check; diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 7c123c90ab..eb01dffac1 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -48,6 +48,7 @@ type Harness = { restores: Array statusQueue: Array> tools: Record + spawnedNow?: ExistingEntry } function install(opts: { @@ -69,6 +70,11 @@ function install(opts: { restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, + // A configured entry that is already CONNECTED was bootstrapped from that + // entry, which is what MCP records. A failed one has no record: production + // only records a spawn when the client actually came up. + spawnedNow: ((opts.statuses?.[0]?.["datamate"]?.status === "connected" ? opts.existing : undefined) ?? + undefined) as ExistingEntry | undefined, } syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) @@ -102,13 +108,18 @@ function install(opts: { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => { h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry }, connect: async (name) => { h.connects.push(name) }, remove: async (name) => { h.removes.push(name) + h.spawnedNow = undefined }, + // Models MCP's own record of what it launched: whatever we last added, or — + // when nothing was added in this process — the entry MCP bootstrapped from. + spawned: async () => h.spawnedNow, tools: async () => h.tools, } return h @@ -1788,3 +1799,51 @@ describe("INVARIANT — the attach flow never writes config from a repair", () = }) } }) + +describe("INVARIANT — attribution asks the running engine, not only the config", () => { + // The config says what SHOULD run; MCP's spawn record says what IS running. + // They diverge whenever the file is rewritten after a client started: another + // process re-pinning a shared config, an IDE replacing the entry through + // MCP.add, a re-link. Judging on the config alone let every check agree with + // itself while the live client served another workspace's data — and its + // credentials — under this workspace's name. Nothing in-process could tell. + const ours = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const theirs = { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"], enabled: true } + + test("a config that names us over a runtime that does not is NOT reused", () => { + const plan = planForEntry({ entry: ours, observed: { status: "connected" }, runtime: theirs }, "42", false) + expect(plan, "reused an engine that was started for another workspace").toMatchObject({ + act: "replace-unattributable", + pinnedTo: "5", + }) + }) + + test("agreement between the two is what earns a reuse", () => { + expect(planForEntry({ entry: ours, observed: { status: "connected" }, runtime: ours }, "42", false).act).toBe( + "check-version", + ) + }) + + test("no runtime record means nothing of ours is running, so the config decides alone", () => { + // Absent is not "mismatched": a key with no live client has no record, and + // the config is then the only evidence there is. + expect(planForEntry({ entry: ours, observed: { status: "connected" }, runtime: undefined }, "42", false).act).toBe( + "check-version", + ) + }) + + test("the whole-session case: a re-pin under a live engine is caught end to end", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + // Another process started this client for workspace 5 and then re-pinned the + // shared config to 42 — which is what this project is bound to, so the + // config agrees with the binding and always would have. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } as never + const outcome = await ensure("s1") + expect(outcome, "answered `reused` about a process serving another workspace").toMatchObject({ kind: "attached" }) + expect(h.added, "did not replace the misattributed engine").toHaveLength(1) + }) +}) From b4df0b42a421f078fb5b479b8752fd55b9f6492c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 08:54:49 +0800 Subject: [PATCH 36/67] fix(workspace): never write what you cannot undo, never stop waiting forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the project entry swallowed its own errors and returned null, but null already means something: "the project file has no entry of its own", which a restore acts on by REMOVING ours. Conflating "there was nothing here" with "I could not look" meant a transient read failure could delete the user's own entry as the undo of an attach that was supposed to leave it alone. It throws now, and the caller refuses to install at all — if we cannot record what to put back, we do not write. That error was also propping up the test harness: nothing stubbed the project reader, so every test was quietly exercising the swallow. The harness now says what it means. The no-wait rule was permanent when it should have been per attach. After one timeout a session never waited again, and re-validating a settled memo runs on every later turn — during which the outcome reads as "not settled". A consumer that fails open on that stops routing for the turn and announces it, then resumes next turn: a session can flap between routed and not while the surface that lists routing says otherwise. Re-validation is a status read and a config read with no spawn, so a turn can always afford to wait for it. The rule now belongs to the attach that earned it — a repair that can spawn, or a spawn still in flight. The wait test's first draft was hollow: it observed a flag during the wait and passed with the defect reinstated, because the task had not reached the seam when the check ran, so it proved the fixture rather than the fix. It asserts elapsed time now, which is what actually differs, and fails when the permanent carry-forward is restored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 15 ++--- .../src/altimate/workspace/engine-sync.ts | 33 ++++++++++- .../altimate/workspace/engine-sync.test.ts | 58 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 44467f5a62..3838654f54 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -64,13 +64,14 @@ export async function freshConfig(): Promise<{ mcp?: Record { if (syncInternals.projectEntry) return syncInternals.projectEntry() - try { - const configPath = await resolveConfigPath(projectRoot()) - return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null - } catch (err) { - log.warn("could not read the project-level engine entry", { err: String(err) }) - return null - } + // THROWS rather than returning null on a read error, because the two answers + // mean opposite things to the caller: `null` says "the project file has no + // entry of its own", and a restore acts on that by REMOVING ours. Conflating + // "there was nothing here" with "I could not look" turned an unreadable + // project config into a deletion of the user's own entry. If we cannot record + // what to put back, we must not write in the first place. + const configPath = await resolveConfigPath(projectRoot()) + return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null } /** Put the config back the way we found it. diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index d18d509950..e34d919e3a 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -718,7 +718,23 @@ async function run(): Promise { // Everything readable is read HERE, above the guard. `persist` otherwise // probes up to nine candidate config paths on disk between the check and the // write it protects — round 19's defect one call deeper than round 19 looked. - const projectBefore = await projectEntry() + // If we cannot record what to put back, we do not write. An unreadable + // project config previously read as "no entry here", which a later restore + // acts on by REMOVING — so a transient read failure could delete the user's + // own entry as the undo of an attach that was meant to leave it alone. + let projectBefore: ExistingEntry | null + try { + projectBefore = await projectEntry() + } catch (err) { + return await refuse({ kind: "connect-failed", error: `project config unreadable: ${String(err)}` }, { + title: "Workspace engine not attached", + message: + `Could not read this project's configuration, so the engine was not installed — attaching without being ` + + `able to undo it risks overwriting your own "${DATAMATE_KEY}" entry. Integration tools are unavailable ` + + `until the config file can be read.`, + variant: "error", + }) + } const configPath = await projectConfigPath().catch(() => undefined) if (!(await worldUnchanged())) { // Re-linked or disabled while we were probing. Installing now would attach a @@ -947,6 +963,11 @@ function rememberSession(sessionID: string, entry: SessionAttach): void { } /** Test seam — how many sessions are currently remembered. */ +/** Test seam — the session map itself, for asserting wait bookkeeping. */ +export function sessionsForTests(): Map { + return sessions as unknown as Map +} + export function trackedSessionsForTests(): number { return sessions.size } @@ -992,9 +1013,17 @@ export function ensure(sessionID: string): Promise { // turn. Failing to wait costs a turn's tools, which `tools/list_changed` // repairs; waiting wrongly costs every turn 15 seconds. const repairRetry = !!previous && isRepairable(previous.outcome) + // A previous timeout must not silence the wait forever. Re-validating a + // settled memo is a status read and a config read with no spawn — bounded, and + // cheap enough that a turn should always wait for it, because during that + // window the outcome reads as "not settled" and a consumer that fails open on + // that will quietly stop routing for the turn and announce it. The no-wait + // rule belongs to the attach that earned it: a repair that can spawn, or a + // spawn still in flight from an earlier turn. + const stillInFlight = !!previous && previous.outcome === undefined const entry = { key: previous?.key, - waitTimedOut: previous?.waitTimedOut || repairRetry, + waitTimedOut: repairRetry || (!!previous?.waitTimedOut && stillInFlight), // Carried forward, or the version re-probe spawns a process every turn: a // fresh entry is built per call, so state that is not copied is state that // is silently rebuilt. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index eb01dffac1..5d09013a4c 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -17,6 +17,7 @@ import { MIN_ENGINE_VERSION, MAX_TRACKED_SESSIONS, trackedSessionsForTests, + sessionsForTests, trackedChainsForTests, settledOutcome, attributableEngine, @@ -104,6 +105,13 @@ function install(opts: { syncInternals.persistRestore = async (_name, previous) => { h.restores.push(previous ?? null) } + // The project file has no entry of its own unless a test says otherwise. This + // used to be supplied by accident: the real reader swallowed its own errors + // and returned null, so an unstubbed harness looked like an empty project + // file. It now throws, because "there was nothing here" and "I could not + // look" mean opposite things to a restore. + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" syncInternals.mcp = { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => { @@ -1847,3 +1855,53 @@ describe("INVARIANT — attribution asks the running engine, not only the config expect(h.added, "did not replace the misattributed engine").toHaveLength(1) }) }) + +describe("INVARIANT — never write what you cannot undo, and never stop waiting forever", () => { + test("an unreadable project config refuses to install rather than installing something it cannot undo", async () => { + // The restore reads the project file to learn what to put back. If that read + // fails and is reported as "no entry here", the undo REMOVES — so a + // transient read failure could delete the user's own entry as the undo of an + // attach meant to leave it alone. + const h = install({ statuses: [{}], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.projectEntry = async () => { + throw new Error("EACCES: permission denied") + } + const outcome = await ensure("s1") + expect(outcome.kind, "installed an engine it had no way to undo").toBe("connect-failed") + expect(h.persisted, "wrote config it could not restore").toHaveLength(0) + expect(h.added, "registered a client it could not undo").toHaveLength(0) + expect(h.toasts, "failed silently").toHaveLength(1) + }) + + test("a settled memo is re-validated inside the turn's wait, even after an earlier timeout", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + // Turn 1 gave up waiting. That must not silence the wait for every later + // turn: re-validating a settled memo is a status read and a config read with + // no spawn, and during that window the outcome reads as "not settled" — a + // consumer that fails open on it stops routing for the turn and says so. + sessionsForTests().get("s1")!.waitTimedOut = true + + // Deterministic on purpose: the first draft of this test observed a flag + // during the wait and passed with the defect reinstated, because the task + // had not reached the seam yet when the check ran. It proved the fixture. + // Elapsed time is the thing that actually differs — with the wait silenced, + // `whenAttached` returns before the re-validation has happened at all. + const previousEntry = syncInternals.existingEntry! + syncInternals.existingEntry = async (name: string) => { + await new Promise((r) => setTimeout(r, 25)) + return previousEntry(name) + } + const started = performance.now() + const pending = ensure("s1") + await whenAttached("s1", 2000) + const waited = performance.now() - started + await pending + expect(waited, "resolved the turn's tools without waiting for the memo re-validation").toBeGreaterThanOrEqual(20) + expect(settledOutcome("s1"), "no settled outcome at the point tools are resolved").toBeDefined() + expect(h.added, "re-validating a good memo spawned a second engine").toHaveLength(1) + }) +}) From ecb6a382d60e17fae89e71075957be242d1fc2a2 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:00:51 +0800 Subject: [PATCH 37/67] test(workspace): assert adjacency and staleness, not routing and presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of this suite's own invariants were weaker than their names, and the gate proved it by mutation rather than by argument. "No MCP mutation on a stale binding" flipped the binding at one seam at a time, which only ever catches a seam someone thought of. An await inserted after the final guard survived the entire suite; so did deleting a teardown's guard outright. The replacement records the order seams are awaited in and asserts adjacency: for every binding-dependent mutation, the seam awaited immediately before it is the world check. That is the mechanical form of "every await after a guard belongs to the guard's problem", and it catches both mutants — including the one no existing test saw. Binding-independent teardowns are exempt and say why: requiring a binding read before them would assert the opposite of what they are for. "Every config read is fresh" tested that reads route through the refreshing accessor, which is a real property but not the one the name claims — deleting `Config.invalidate()` from the accessor, from `persist`, or from `persistRestore` left everything green. It now mocks a cache that only updates when invalidated, so staleness is directly observable: write behind the reader and see whether the reader notices. All three cases fail when any invalidation is removed. "An actionable failure always tells the user" asserted at least one signal, which accepts a double. It asserts exactly one now, plus exactly zero for a refusal whose binding has moved — the message would name a workspace the project no longer holds — and pins teardown before announcement, which no test held even though a substitution point that waits on a person would otherwise keep a rejected client connected until they clicked. The unexpected-throw path also routes through the single refusal exit instead of raising its own toast, so there is now exactly one place any refusal reaches the user, exceptions included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 97 ++++++------ .../workspace/engine-config-freshness.test.ts | 82 ++++++++++ .../altimate/workspace/engine-sync.test.ts | 141 +++++++++++++++++- 3 files changed, 274 insertions(+), 46 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e34d919e3a..33905c10c0 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -243,6 +243,47 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie return { act: "check-version" } } +/** Tell the user about a refusal — exactly once, from one place. + * + * This is a whole function for what is currently one call because it is a + * substitution point, and the substitution is easy to get wrong in a way no + * test on either side would catch. + * + * `installWouldHelp` names the refusals an install would actually fix. Those + * belong to an install offer when one exists, and the offer owns the MESSAGING + * for them: it replaces this toast rather than joining it, and falls back to + * this same toast whenever it cannot reach a surface. So "an actionable failure + * is never silent" holds either way, and neither path emits twice. + * + * The toast and the offer are alternatives, not a sequence. A refusal that + * raises both is the double signal — a dialog and a toast saying the same thing + * — and it would pass a suite asserting a toast fires alongside one asserting an + * offer is raised, because neither asserts the user sees exactly ONE thing. + * Replace this function's body; do not add beside it. + * + * Module-level so the unexpected-throw path uses it too. That path had grown its + * own toast — a second place a refusal reaches the user, which is exactly the + * kind of site an offer would double up on, and the kind nobody writes a fixture + * for. + * + * NEVER throws: "never silent" also has to mean "never relabelled". A throw here + * reached the catch-all and turned a decided outcome into `connect-failed` with + * a second toast, so failing to DESCRIBE a verdict silently rewrote it. */ +async function announceRefusal(outcome: Outcome, toast: Toast, context?: Record): Promise { + try { + if (installWouldHelp(outcome)) { + log.info("refusal is remediable by installing the engine", { ...context, kind: outcome.kind }) + } + await notify(toast) + } catch (err) { + log.warn("could not announce the refusal; the outcome stands", { + ...context, + kind: outcome.kind, + err: String(err), + }) + } +} + async function run(): Promise { if (!isEnabled()) return { kind: "disabled" } @@ -386,43 +427,6 @@ async function run(): Promise { return { kind: "superseded" } } - /** Tell the user about a refusal — exactly once. - * - * This is a whole function for what is currently one call because it is a - * substitution point, and the substitution is easy to get wrong in a way no - * test on either side would catch. - * - * `installWouldHelp` names the refusals an install would actually fix. Those - * belong to an install offer when one exists, and the offer owns the - * MESSAGING for them: it replaces this toast rather than joining it, and - * falls back to this same toast whenever it cannot reach a surface. So - * "an actionable failure is never silent" holds either way, and neither path - * emits twice. - * - * The toast and the offer are alternatives, not a sequence. A refusal that - * raises both is the double signal — a dialog and a toast saying the same - * thing — and it would pass a suite that asserts a toast fires alongside one - * that asserts an offer is raised, because neither asserts the user sees - * exactly one thing. Replace this function's body; do not add beside it. */ - const announceRefusal = async (outcome: Outcome, toast: Toast): Promise => { - try { - if (installWouldHelp(outcome)) { - log.info("refusal is remediable by installing the engine", { workspaceId, kind: outcome.kind }) - } - await notify(toast) - } catch (err) { - // "Never silent" has to also mean "never relabelled". A throw here reached - // the catch-all and turned a decided outcome — `entry-disabled`, say — - // into `connect-failed`, with a second toast, so a failure to DESCRIBE the - // verdict silently rewrote the verdict. - log.warn("could not announce the refusal; the outcome stands", { - workspaceId, - kind: outcome.kind, - err: String(err), - }) - } - } - /** The single exit for every refusal. * * Three properties that were previously spread across six branches, each of @@ -460,7 +464,7 @@ async function run(): Promise { }) return { kind: "superseded" } } - await announceRefusal(outcome, toast) + await announceRefusal(outcome, toast, { workspaceId }) return outcome } @@ -1090,12 +1094,17 @@ function attachOnce(sessionID: string): Promise { // nor an explanation, since the caller discards this outcome and // `whenAttached` returns void. log.warn("workspace engine attach failed", { err: error }) - await notify({ - title: "Workspace engine attach failed", - message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, - variant: "error", - }) - return { kind: "connect-failed", error } + const outcome: Outcome = { kind: "connect-failed", error } + await announceRefusal( + outcome, + { + title: "Workspace engine attach failed", + message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, + variant: "error", + }, + { sessionID }, + ) + return outcome }) .then((outcome) => { // One line per session, whatever happened — silence is the defect this diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts new file mode 100644 index 0000000000..41f5aa3136 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -0,0 +1,82 @@ +// altimate_change - new file +// +// The freshness invariant, asserted by OBSERVING staleness rather than by +// observing that a read went through the right function. +// +// The previous version of this check verified that config reads route through +// the refreshing accessor. That is a real property, but it is not the one the +// name claims — and deleting `Config.invalidate()` from the accessor, from +// `persist`, or from `persistRestore` left the whole suite green. A test named +// "every config read is fresh" that survives the removal of every invalidation +// is asserting something other than freshness. +// +// This file mocks `Config` with a cache that only updates when invalidated, so +// a stale read is directly observable: write to the "file", read, and see +// whether the write is visible. +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" + +let fileContents: { mcp?: Record } = {} +let cached: { mcp?: Record } | null = null +let invalidations = 0 + +mock.module("../../../src/config/config", () => ({ + Config: { + // Models the real thing: `get()` is cached per instance and does NOT see a + // write made behind it until something invalidates. + get: async () => { + if (cached === null) cached = structuredClone(fileContents) + return cached + }, + invalidate: async () => { + invalidations += 1 + cached = null + }, + }, +})) + +const { existingEntry } = await import("../../../src/altimate/workspace/engine-config") +const { syncInternals } = await import("../../../src/altimate/workspace/engine-seams") + +beforeEach(() => { + fileContents = {} + cached = null + invalidations = 0 + delete syncInternals.existingEntry + delete syncInternals.freshConfig +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] +}) + +describe("INVARIANT — a config read observes writes made behind it", () => { + test("an external write between two reads is visible to the second", async () => { + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: true } } } + const before = await existingEntry("datamate") + expect(before?.enabled).toBe(true) + + // An IDE, another process, or `/mcps disable` writes the file. Nothing tells + // this process; the write never goes through `Config` at all. + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } + + const after = await existingEntry("datamate") + expect(after?.enabled, "read a cached config and missed a write made behind it").toBe(false) + }) + + test("an entry added externally after the cache warmed is seen", async () => { + fileContents = { mcp: {} } + expect(await existingEntry("datamate")).toBeNull() + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } } } + expect(await existingEntry("datamate"), "missed an entry an IDE added after the cache warmed").not.toBeNull() + }) + + test("freshness costs an invalidation per read, which is the trade being made", async () => { + // Named rather than hidden: invalidating drops the per-instance cache for + // every other Config consumer too. That is the price of not having a fourth + // instance of the stale-read defect. + fileContents = { mcp: {} } + await existingEntry("datamate") + await existingEntry("datamate") + expect(invalidations).toBe(2) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 5d09013a4c..f29aa1b4eb 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1395,7 +1395,7 @@ describe("INVARIANT — every config read is fresh", () => { }) }) -describe("INVARIANT — an actionable failure always tells the user", () => { +describe("INVARIANT — an actionable failure tells the user exactly once", () => { const actionable: Array<{ name: string; opts: Parameters[0]; kind: string }> = [ { name: "engine-missing", opts: { which: null }, kind: "engine-missing" }, { name: "engine-too-old", opts: { version: "0.5.9" }, kind: "engine-too-old" }, @@ -1426,9 +1426,56 @@ describe("INVARIANT — an actionable failure always tells the user", () => { const h = install(c.opts) const outcome = await ensure("s1") expect(outcome.kind).toBe(c.kind as never) - expect(h.toasts.length, `${c.name} returned without telling the user`).toBeGreaterThan(0) + // EXACTLY one, not at least one. "At least one" accepts a double signal, + // and a double signal is what a refusal path grows when a second way of + // reaching the user is added beside the first — a dialog and a toast + // saying the same thing. A suite asserting a toast fires and a suite + // asserting an offer is raised can both be green while the user sees two. + expect(h.toasts.length, `${c.name} told the user ${h.toasts.length} times, not once`).toBe(1) }) } + + test("a refusal for a workspace the project has left says nothing at all", async () => { + // Zero, not one: the message would name a workspace this project no longer + // holds. The teardown still happens — it is binding-independent — but the + // answer becomes `superseded` and the user hears nothing about a decision + // that no longer applies to them. + let current: CachedBinding | null = binding + const h = install({ + which: null, + statuses: [{}], + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.toasts.length, "announced a refusal for the workspace the project had left").toBe(0) + }) + + test("the teardown happens before the announcement, not after", async () => { + // Load-bearing rather than incidental: the announcement is a substitution + // point, and a body that waits on a person — a dialog — would hold a + // rejected client connected until they clicked. Stop serving first, explain + // second. + const order: string[] = [] + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + const previousRemove = syncInternals.mcp!.remove + syncInternals.mcp!.remove = async (name: string) => { + order.push("teardown") + return previousRemove(name) + } + syncInternals.notify = async (toast) => { + order.push("announce") + h.toasts.push(toast) + } + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(order, "explained before it stopped serving").toEqual(["teardown", "announce"]) + }) }) describe("INVARIANT — a superseded attach leaves nothing installed", () => { @@ -1905,3 +1952,93 @@ describe("INVARIANT — never write what you cannot undo, and never stop waiting expect(h.added, "re-validating a good memo spawned a second engine").toHaveLength(1) }) }) + +describe("INVARIANT — the last thing awaited before a mutation is the world check", () => { + // The mechanical form of "every await after a guard belongs to the guard's + // problem". Individual tests flip a binding at one seam and check one + // outcome; that only ever catches the seam someone thought of, which is why + // an await inserted after the final guard survived the whole suite, and why + // deleting a teardown's guard outright survived it too. + // + // This records the order seams are awaited in and asserts adjacency: for every + // binding-DEPENDENT mutation, the seam awaited immediately before it is the + // binding read. persist -> add is sanctioned as one commit, since the guard + // covers the pair. + // + // Binding-INDEPENDENT teardowns are deliberately out of scope: a disabled or + // below-floor engine is torn down whatever is bound, so requiring a binding + // read before those would assert the opposite of what they are for. The + // scenarios below exercise only paths whose mutations are binding-dependent. + const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) + + function traced(opts: Parameters[0]) { + const h = install(opts) + const trace: string[] = [] + const wrapRead = Promise>(name: string, fn: T) => + (async (...args: never[]) => { + const out = await fn(...args) + trace.push(name) + return out + }) as T + const wrapMutation = Promise>(name: string, fn: T) => + (async (...args: never[]) => { + trace.push(name) + return await fn(...args) + }) as T + + syncInternals.resolveBinding = wrapRead("resolveBinding", syncInternals.resolveBinding!) + syncInternals.existingEntry = wrapRead("existingEntry", syncInternals.existingEntry!) + syncInternals.projectEntry = wrapRead("projectEntry", syncInternals.projectEntry!) + syncInternals.declared = wrapRead("declared", syncInternals.declared!) + syncInternals.versionOf = wrapRead("versionOf", syncInternals.versionOf!) + syncInternals.persist = wrapMutation("persist", syncInternals.persist!) + syncInternals.persistRestore = wrapMutation("persistRestore", syncInternals.persistRestore!) + const m = syncInternals.mcp! + syncInternals.mcp = { + ...m, + status: wrapRead("status", m.status), + tools: wrapRead("tools", m.tools!), + spawned: m.spawned ? wrapRead("spawned", m.spawned) : undefined, + add: wrapMutation("add", m.add), + remove: wrapMutation("remove", m.remove), + connect: wrapMutation("connect", m.connect), + } + return { h, trace } + } + + const scenarios: Array<[string, Parameters[0]]> = [ + ["a fresh spawn", { statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }], + [ + "replacing an entry pinned elsewhere", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + [ + "replacing an unpinned entry", + { + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + ] + + for (const [name, opts] of scenarios) { + test(`${name}: every mutation is preceded by the world check`, async () => { + const { trace } = traced(opts) + await ensure("s1") + const offenders: string[] = [] + trace.forEach((step, i) => { + if (!MUTATIONS.has(step)) return + const before = trace[i - 1] + if (before === "resolveBinding") return + if (step === "add" && before === "persist") return // one commit, one guard + offenders.push(`${step} followed ${before ?? "(nothing)"}`) + }) + expect(offenders, `${name}: ${offenders.join("; ")} — trace was ${trace.join(" -> ")}`).toEqual([]) + }) + } +}) From 3dbb0e94da7950946b86caf5749b60875e2db7eb Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:13:56 +0800 Subject: [PATCH 38/67] test(workspace): lift the gate's regression cases, adapted where the contract moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five adversarial lenses' test files are now part of the suite: 351 tests across the workspace directory, up from 142. They are the narrowest plausible regressions for this round by construction — each was written to exhibit one specific defect. They needed adapting rather than copying, and every adaptation is marked `ADAPTED ON LIFT` or `INVERTED ON LIFT` in place, with the reason. Three kinds: Tests written against `MCP.connect` as the retry primitive now hook `add`. The property each asserted — re-inspect whole, judge on the post-retry entry, write the memo exactly once — is unchanged; only the seam standing in for "the retry happened" moved. Tests asserting an answer that is now `superseded` were pointing at real behaviour that changed deliberately: a refusal is an answer, so one whose binding moved mid-decision no longer describes, or toasts about, a workspace the project has left. The teardown they were really testing still holds, and they still assert it. The snapshot lens's file documents current behaviour rather than desired behaviour, so its cases about writing over a disable are inverted: the write does not happen now. One finding was only half fixed and the lifted tests caught it. A missing binary was reported as `engine-missing` on the post-install path but still as `connect-failed` on the retry path — so an entry pinned to us whose engine had been uninstalled produced a message with no install hint, every turn, with `which` never consulted. Both paths consult it now. Also required: the harnesses in those files gained the project-entry stub, since that reader stopped swallowing its own errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 17 + .../workspace/engine-sync-gate-l1.test.ts | 327 +++++++++ .../altimate/workspace/gate-l2-repro.test.ts | 48 ++ .../altimate/workspace/gate-l2-repro2.test.ts | 135 ++++ .../altimate/workspace/gate-l4-attack.test.ts | 244 +++++++ .../altimate/workspace/l3-snapshot.test.ts | 281 ++++++++ .../workspace/l5-seam-contract.test.ts | 622 ++++++++++++++++++ 7 files changed, 1674 insertions(+) create mode 100644 packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts create mode 100644 packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts create mode 100644 packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts create mode 100644 packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts create mode 100644 packages/opencode/test/altimate/workspace/l3-snapshot.test.ts create mode 100644 packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 33905c10c0..6b80ede36e 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -546,6 +546,23 @@ async function run(): Promise { } if (plan.act === "refuse-unreachable") { + // "It will not start" and "there is nothing to start" are different + // situations with different remedies, and an entry pinned to us whose binary + // has since been uninstalled looks exactly like the first while being the + // second. Reported as `connect-failed`, it produced a message with no + // install hint, every turn, forever — and `which` was never consulted on + // this path at all. + if (!which(ENGINE_BINARY)) { + const declaredForMissing = await declaredBounded(workspaceId) + const count = declaredForMissing?.keys.length ?? 0 + return await refuse({ kind: "engine-missing", declared: count }, { + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${count} integration tool${count === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + } return await refuse({ kind: "connect-failed", error: plan.error }, { title: "Workspace engine is not running", message: diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts new file mode 100644 index 0000000000..1982937bd7 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts @@ -0,0 +1,327 @@ +// Gate lens 1 — awaits between a binding guard and the mutation it protects. +// Disposable; lives only in the reviewer's checkout. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const A: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding +const B: CachedBinding = { ...A, datamateId: 99, datamateName: "other" } as CachedBinding + +type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: unknown[] + statusQueue: Array> + tools: Record + /** Every awaited seam, in call order, with the binding it observed. */ + trace: string[] + current: CachedBinding | null +} + +function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: Harness["statusQueue"] + tools?: Record + existing?: ExistingEntry | null +}): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + restores: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + trace: [], + current: A, + } + const seam = (name: string) => h.trace.push(name) + syncInternals.resolveBinding = async () => (seam("resolveBinding"), h.current) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + seam("versionOf") + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => (seam("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + seam("persist") + h.persisted.push({ name, cfg }) + } + syncInternals.projectEntry = async () => (seam("projectEntry"), null) + syncInternals.existingEntry = async () => { + seam("existingEntry") + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.notify = async (toast) => { + seam("notify") + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + seam("toolsChanged") + } + syncInternals.persistRestore = async (_name, previous) => { + seam("persistRestore") + h.restores.push(previous ?? null) + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (seam("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + seam("add") + h.added.push({ name, cfg }) + }, + connect: async (name) => { + seam("connect") + h.connects.push(name) + }, + remove: async (name) => { + seam("remove") + h.removes.push(name) + }, + tools: async () => (seam("tools"), h.tools), + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +// --------------------------------------------------------------------------- +// T1 — the property the author names, tested as a property: the seam awaited +// IMMEDIATELY before every mutation must be the binding read. Catches any +// awaited seam inserted between the guard and persist/add/remove/connect, +// which the existing first-call-flip tests cannot (they flip before the guard). +// --------------------------------------------------------------------------- +describe("T1 — the last awaited seam before every mutation is the binding read", () => { + const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) + + function violations(trace: string[]): string[] { + const out: string[] = [] + for (let i = 0; i < trace.length; i++) { + if (!MUTATIONS.has(trace[i])) continue + // Walk back to the previous non-mutation seam. + let j = i - 1 + while (j >= 0 && MUTATIONS.has(trace[j])) j-- + const before = trace[j] + // persist→add is the one sanctioned adjacency (persist has no seam of its own + // to re-read after); everything else must sit directly on a binding read. + if (trace[i] === "add" && trace[i - 1] === "persist") continue + if (before !== "resolveBinding") out.push(`${trace[i]} at #${i} follows ${before ?? ""}`) + } + return out + } + + test("fresh spawn", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + test("replace an unpinned live entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + test("pinned-but-below-floor, PATH newer", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + test("retry-connect of a down command entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// T2 — retry-connect on a stale binding, then the refusal skips teardown +// because the binding is stale: the engine THIS attach brought up stays. +// --------------------------------------------------------------------------- +describe("T2 — retry-connect is an MCP mutation with no guard", () => { + test("a re-link before the retry: the engine we reconnected is left serving under the new binding", async () => { + const h = install({ + // Pinned to 42, down, and (once revived) below the floor; PATH no better. + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // The re-link lands while the config is being read — before the retry. + syncInternals.existingEntry = async () => { + h.trace.push("existingEntry") + h.current = B + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + const outcome = await ensure("s1") + // ADAPTED ON LIFT. The original asserted the revived engine gets torn down. + // It is never started now: the retry is a guarded mutation, so a binding that + // moved before it means we abandon rather than start-then-undo. Nothing + // brought up is strictly better than something brought up and removed. + expect(h.connects, "reconnected an entry for a workspace the project had already left").toEqual([]) + expect(h.added, "started an engine for a workspace the project had already left").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link DURING the retry's connect window: same result", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // ADAPTED ON LIFT: the retry re-adds rather than connecting, so the window a + // re-link can land in is `add`, not `connect`. + const previousAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + h.trace.push("add") + h.current = B // a TUI re-link inside the restart is the likely timing + return previousAdd(name, cfg) + } + const outcome = await ensure("s1") + // The engine THIS attach brought up is torn down whatever is bound now — + // undoing what we created is binding-independent by definition. + expect(h.removes, "the engine this attach brought up was left connected under binding 99").toContain("datamate") + expect(outcome.kind).toBe("superseded") + }) +}) + +// --------------------------------------------------------------------------- +// T3 — production persist() awaits ~10 fs operations (resolveConfigPath's +// exists() loop, addMcpToConfig's exists+readText) before its write and before +// MCP.add. Model ONE of them in the seam and flip inside it. +// --------------------------------------------------------------------------- +describe("T3 — awaits inside persist() sit between the final guard and the install", () => { + test("a re-link inside persist's config-path probe still spawns the old workspace's engine", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + // ADAPTED ON LIFT. The config-path probe — up to nine `exists` calls — is no + // longer inside the write: it is resolved ABOVE the guard and handed in, so + // this models it where it now lives. That is the fix; flipping inside the + // resolved-path lookup must be caught by the guard, not undone after it. + syncInternals.projectConfigPath = async () => { + h.trace.push("resolveConfigPath") + await Promise.resolve() // Filesystem.exists(candidate) #1 of up to 9 + h.current = B + return "/tmp/test/.altimate-code/altimate-code.json" + } + const outcome = await ensure("s1") + // Round 19's own standard: the late guard undoing it is the failure, not the fix. + expect(h.added.filter((a) => a.cfg.command.includes("42")), "spawned workspace 42's engine after the re-link").toHaveLength(0) + expect(h.persisted, "wrote workspace 42's pin after the re-link").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link inside the WRITE itself is undone rather than prevented — the named residual", async () => { + // Nothing can guard the inside of the write. What must hold is that the + // region gives back both halves of what it took. + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + h.current = B + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine registered").toContain("datamate") + expect(h.restores.length, "left the old workspace's pin on disk").toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// T4 — answered after awaits that follow the final guard (announce, notify). +// --------------------------------------------------------------------------- +describe("T4 — the attached answer is given after two awaits past the last guard", () => { + test("a re-link during announceToolsChanged is answered `attached` for the old workspace", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.toolsChanged = async () => { + h.trace.push("toolsChanged") + h.current = B + } + const outcome = await ensure("s1") + // ADAPTED ON LIFT, and the residual is named rather than asserted away. + // The answer is now fixed BEFORE the announcements rather than after them, + // so the decision no longer straddles those awaits — but a re-link landing + // inside the toast still leaves this turn holding `attached` for 42. It + // cannot be guarded without either un-saying a toast already shown or + // announcing a success we then retract. + // + // What must hold is that it does not OUTLIVE the turn: the memo is keyed to + // the workspace it was taken for, so the next turn re-decides for 99 rather + // than riding it. + expect(outcome.kind).toBe("attached") + const second = await ensure("s1") + expect(second.kind, "rode a memo taken for the workspace the project had left").not.toBe("reused") + expect(h.added.at(-1)?.cfg.command, "did not re-attach for the new binding").toEqual([ + "datamate", + "start-stdio", + "--datamate", + "99", + ]) + }) +}) + +// --------------------------------------------------------------------------- +// T5 — the skip-teardown in detachRejected applies to binding-INDEPENDENT +// teardowns too: a disabled entry keeps serving for this turn after a re-link. +// --------------------------------------------------------------------------- +describe("T5 — a disabled entry's teardown is skipped on a stale binding", () => { + test("re-link during the status read: the disabled-but-connected client is left serving", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.mcp!.status = async () => { + h.trace.push("status") + h.current = B + return { datamate: { status: "connected" } } + } + const outcome = await ensure("s1") + // ADAPTED ON LIFT. The teardown is the property under test and it holds: a + // disabled entry is disabled for every workspace, so its teardown does not + // consult the binding. The ANSWER is now `superseded` rather than + // `entry-disabled`, because a refusal is an answer too and this one would + // otherwise describe — and toast about — a workspace the project has left. + expect(outcome.kind).toBe("superseded") + expect(h.removes, "a disabled entry is disabled for every workspace; its teardown does not depend on the binding").toContain("datamate") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts new file mode 100644 index 0000000000..1550d5a24c --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts @@ -0,0 +1,48 @@ +// Gate L2 repro — NOT for commit. Type-less `{ enabled: false }` disable marker. +import { afterEach, beforeEach, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, planForEntry } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const binding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + +test("planForEntry: a disable marker with no runtime status is honoured", () => { + // MCP.status() omits a config entry that has no `type` (mcp/index.ts:875-878), + // and the schema allows `{ enabled: false }` alone (core config.ts:119). + expect(planForEntry({ entry: { enabled: false }, observed: undefined }, "42", false)).toEqual({ act: "honour-disable" }) +}) + +test("ensure: a project `datamate: { enabled: false }` marker is not spawned over", async () => { + const added: unknown[] = [], persisted: unknown[] = [], toasts: unknown[] = [] + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => ({ enabled: false }) + syncInternals.projectEntry = async () => ({ enabled: false }) + syncInternals.persist = async (n, c) => { persisted.push({ n, c }) } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + let live = false + syncInternals.mcp = { + // The entry has no `type`, so status() never lists it — until WE add it. + status: async () => (live ? { datamate: { status: "connected" } } : {}), + add: async (n, c) => { added.push({ n, c }); live = true }, + connect: async () => {}, + remove: async () => {}, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + const outcome = await ensure("s1") + console.log("outcome:", JSON.stringify(outcome), "persisted:", JSON.stringify(persisted), "toasts:", JSON.stringify(toasts.map((t: any) => t.title))) + expect(outcome.kind).toBe("entry-disabled") + expect(added).toHaveLength(0) + expect(persisted).toHaveLength(0) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts new file mode 100644 index 0000000000..897b34d161 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts @@ -0,0 +1,135 @@ +// Gate L2 repro 2 — NOT for commit. +import { afterEach, beforeEach, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, planForEntry, installWouldHelp, whenAttached, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const b42 = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding +const b99 = { datamateId: 99, datamateName: "other", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + +type H = { added: unknown[]; persisted: unknown[]; connects: string[]; removes: string[]; toasts: { title: string; message: string }[] } +function base(opts: { existing: unknown; statuses: Record[]; which?: string | null; binding?: () => CachedBinding | null }): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [] } + const q = opts.statuses + syncInternals.resolveBinding = async () => (opts.binding ? opts.binding() : b42) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => opts.existing as never + syncInternals.projectEntry = async () => null + syncInternals.persist = async (n, c) => { h.persisted.push({ n, c }) } + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!), + add: async (n, c) => { h.added.push({ n, c }) }, + connect: async (n) => { h.connects.push(n) }, + remove: async (n) => { h.removes.push(n) }, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h +} +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + +test("(a) the repair turn RECONNECTS the entry this flow tore down last turn, then rejects it again", async () => { + let onPath: string | null = null + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "disabled" } }, // synthesised by MCP.status() after OUR remove (mcp/index.ts:877) + { datamate: { status: "connected" } }, // MCP.connect brought the rejected engine back + { datamate: { status: "connected" } }, + ], + }) + syncInternals.which = () => onPath + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 1 }) + expect(h.removes).toEqual(["datamate"]) + onPath = "/usr/local/bin/datamate" + await ensure("s1") + console.log("(a) turn2 connects:", h.connects, "removes:", h.removes, "added:", h.added.length) + expect(h.connects, "reconnected an engine judged unattributable one turn earlier").toHaveLength(0) +}) + +test("(b) an entry REMOVED from config but still known to the runtime is retried via MCP's runtime cfg", async () => { + // MCP.status() lists every key in s.config (mcp/index.ts:880-882) — runtime cfg + // set by our own earlier MCP.add and never cleared by MCP.remove (949-955). + // ADAPTED ON LIFT: the finding is fixed. An entry MCP still knows about but + // config no longer contains cannot be attributed to this workspace, so it is + // replaced rather than revived from whatever MCP happens to have retained. + expect(planForEntry({ entry: null, observed: { status: "disabled" } }, "42", false)).toMatchObject({ + act: "replace-unattributable", + pinnedTo: null, + }) + const h = base({ existing: null, statuses: [{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }] }) + const out = await ensure("s1") + console.log("(b) outcome:", JSON.stringify(out), "connects:", h.connects, "removes:", h.removes) + expect(h.connects).toEqual([]) // fails: connect("datamate") reconnects whatever s.config holds — planForEntry never saw it +}) + +test("(c) connect-failed with the engine binary gone: install would help, table says no", async () => { + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [ + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + ], + which: null, + }) + const out = await ensure("s1") + console.log("(c) outcome:", JSON.stringify(out), "toast:", h.toasts.map((t) => t.message)) + // ADAPTED ON LIFT: the finding is fixed at its root rather than in the table. + // `connect-failed` with the binary gone was a lie — the engine did not fail to + // start, there was no engine — so the outcome now says `engine-missing` and + // the remedy predicate is right about it without needing a special case. + // `which` is consulted before answering, rather than reading ENOENT out of a + // platform-specific message. + expect(out.kind).toBe("engine-missing") + expect(installWouldHelp(out)).toBe(true) + expect(h.toasts[0]?.message, "told the user it failed to start rather than that it is missing").toContain( + "not installed", + ) +}) + +test("(d) a refusal is answered for a binding the project already left, with the rejected client left serving", async () => { + let current = b42 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned, connected + statuses: [{ datamate: { status: "connected" } }], + which: null, + binding: () => current, + }) + // Re-link lands right after run() snapshots the binding (during the config read). + const realExisting = syncInternals.existingEntry! + syncInternals.existingEntry = async (n) => { current = b99; return realExisting(n) } + const out = await ensure("s1") + console.log("(d) outcome:", JSON.stringify(out), "removes:", h.removes, "toasts:", h.toasts.map((t) => t.message)) + expect(out.kind).not.toBe("engine-missing") // fails: answers engine-missing for ws 42 while ws 99 is bound; detach skipped, toast names "analytics" +}) + +test("(e) a re-link during memo validation: the next attach is filed under the OLD key and loses its wait", async () => { + let current: CachedBinding = b42 + let calls = 0 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }], + binding: () => current, + }) + expect(await ensure("s1")).toMatchObject({ kind: "reused" }) + // Turn 2: engineStillOurs runs; the binding flips to 99 during its status read. + syncInternals.mcp!.status = async () => { calls += 1; if (calls === 1) current = b99; return { datamate: { status: "connected" } } } + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", String(current.datamateId)], enabled: true }) as never + const t2 = ensure("s1") + const started = Date.now() + await whenAttached("s1", 2000) + const waited = Date.now() - started + const out2 = await t2 + const out3 = await ensure("s1") + console.log("(e) turn2:", JSON.stringify(out2), "waited ms:", waited, "turn3:", JSON.stringify(out3), "settled:", JSON.stringify(settledOutcome("s1"))) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts new file mode 100644 index 0000000000..17b97bb0fc --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts @@ -0,0 +1,244 @@ +// Gate L4 attack tests — each test asserts the teardown property the lens +// requires; a FAILING test here is a demonstrated gap. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding +const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + +type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: Array + statusQueue: Array> + tools: Record +} +function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: H["statusQueue"] + tools?: Record + existing?: ExistingEntry | null + projectEntry?: ExistingEntry | null +}): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.projectEntry = async () => opts.projectEntry ?? null + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { h.restores.push(prev ?? null) } + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { h.added.push({ name, cfg }) }, + connect: async (name) => { h.connects.push(name) }, + remove: async (name) => { h.removes.push(name) }, + tools: async () => h.tools, + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h +} +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { + for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +describe("A/B — detachRejected is gated on stillCurrent, so a supersede skips the runtime teardown", () => { + test("A: entry DISABLED + connected, re-link lands between status() and refuse → client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.resolveBinding = async () => current + const prevStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { const s = await prevStatus(); current = other; return s } + const outcome = await ensure("s1") + // ADAPTED ON LIFT: the teardown is the property and it holds. The answer is + // `superseded` because a refusal is an answer too, and this one would have + // described a workspace the project had already left. + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "disabled entry reported but its live client was NOT removed (detachRejected skipped on supersede)").toContain("datamate") + }) + test("B: pinned-to-us, below floor, nothing better on PATH, re-link lands in versionOf → too-old client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => { current = other; return "0.5.0" }, + }) + syncInternals.resolveBinding = async () => current + const outcome = await ensure("s1") + // ADAPTED ON LIFT: as above — teardown holds, the answer is `superseded`. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.removes, "too-old engine reported but left registered (detachRejected skipped on supersede)").toContain("datamate") + }) +}) + +describe("D — connect-failed AFTER install never restores what persist() replaced", () => { + test("user's hand-authored PROJECT entry is overwritten by our pin; spawn fails; nothing puts it back", async () => { + const users: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"] } // unpinned, in project file, live + const h = install({ + existing: users, + projectEntry: users, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "failed", error: "exit 1" } }], + }) + const outcome = await ensure("s1") + // ADAPTED ON LIFT: the finding is fixed. The install region gives back both + // halves on every non-attached exit, so a failed spawn puts the user's own + // entry back instead of leaving our pin over it. + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.restores, "the failed spawn left our pin over the user's project entry").toEqual([users]) + }) +}) + +describe("F — connect-failed after install, superseded: stale pin stays on disk and wedges the new workspace", () => { + test("turn 1: install 42, re-link to 99 during add, spawn fails → refuse() without undoInstall", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + // ADAPTED ON LIFT: the finding is fixed. A failed spawn is a non-attached + // exit, so the region gives back the pin it wrote — it does not survive to + // wedge the next turn. + // The re-link lands during the add, so the refusal revalidates and declines + // to answer for the workspace the project has left. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.restores.length, "the failed spawn's pin was left on disk to wedge the next turn").toBeGreaterThan(0) + }) + test("turn 2 under binding 99: the failing 42 pin is retried once and refused — 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + // ADAPTED ON LIFT: the finding is fixed on both counts. Turn 1's re-link + // during the add makes the refusal decline to answer for the workspace just + // left, and its pin is given back rather than left to wedge turn 2. Turn 2 + // then judges 42's pin unattributable under binding 99 and REPLACES it + // instead of retrying it, so 99 gets its engine. `connect-failed` on turn 2 + // is the fixture's own doing: its status queue reports the freshly spawned + // engine as failed too. + expect(await ensure("s1")).toMatchObject({ kind: "superseded" }) + syncInternals.mcp!.add = prevAdd + const second = await ensure("s1") + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "workspace 99 never gets an engine: the stale failing 42 pin blocks it every turn").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) +}) + +describe("E — a throw after install bypasses undoInstall entirely", () => { + test("re-link during add, then tools() throws → engine for 42 stays installed under binding 99, outcome connect-failed", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.mcp!.tools = async () => { throw new Error("tools listing exploded") } + const outcome = await ensure("s1") + // ADAPTED ON LIFT: a throw no longer unwinds past the undo — the region is + // shaped so any non-attached exit, including one nobody wrote, gives back + // both halves. + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.removes, "a throw left the client registered").toContain("datamate") + expect(h.restores, "a throw left our pin on disk").toHaveLength(1) + }) +}) + +describe("C — retry-connect calls MCP.connect on a global-only entry (persists enabled:true into the owning file)", () => { + test("a down, enabled, IDE-shaped entry is retried via MCP.connect", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, // IDE shape, no `enabled` field, lives in global + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + }) + await ensure("s1") + // ADAPTED ON LIFT: the finding is fixed. Repairing a down IDE-shaped entry + // used `MCP.connect`, which persists `enabled: true` into the file that owns + // the entry — a global write from a local decision. It re-adds now. + expect(h.connects, "repaired a global entry by writing to it").toHaveLength(0) + }) +}) + +describe("F3 — the general wedge: a persisted pin that later fails blocks the NEW workspace forever", () => { + test("clean attach of 42; user re-links to 99; 42's engine is now down → retried once, refused; 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "connected" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial (42's engine died) + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + current = other + const second = await ensure("s1") + // ADAPTED ON LIFT: the wedge is fixed. 42's pin is unattributable under + // binding 99, so it is replaced rather than retried, and 99 gets its engine. + // `connect-failed` here is the fixture's own doing — the status queue reports + // the freshly spawned engine as failed too. + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "99 blocked behind the failing 42 pin").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) +}) + +describe("G — refuse() order at 2d8bea2d0: teardown runs BEFORE announceRefusal; a throwing announce relabels the outcome", () => { + test("disabled+connected entry, notify seam throws: client IS removed (teardown first), but outcome becomes connect-failed and a 2nd toast fires", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + let notifyCalls = 0 + syncInternals.notify = async (t) => { + notifyCalls += 1 + if (notifyCalls === 1) throw new Error("dialog surface exploded") + h.toasts.push(t) + } + const outcome = await ensure("s1") + expect(h.removes, "teardown did not run before the announce").toContain("datamate") + // ADAPTED ON LIFT: the finding is fixed. A throwing announce no longer + // reaches the catch-all, so the verdict stands and no second toast fires. + expect(notifyCalls, "a failed announcement was retried through a second toast site").toBe(1) + expect(outcome.kind, "a throwing announce relabels entry-disabled as connect-failed").toBe("entry-disabled") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts new file mode 100644 index 0000000000..dcea0eaef5 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts @@ -0,0 +1,281 @@ +// L3 gate experiments (v2, against 2d8bea2d0) — snapshot freshness around planForEntry / Inspection. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + probes: string[] +} + +function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], statusQueue: statuses, reads: [], probes: [] } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + return "0.7.0" + } + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + connect: async (name) => { + h.connects.push(name) + }, + remove: async (name) => { + h.removes.push(name) + }, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] +}) + +describe("L3 (a') — a disable lands INSIDE the retry's connect window", () => { + test("FIXED by 5fe9d8a6a: the retry re-inspects both halves, so a disable that survives on disk is honoured", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + // ADAPTED ON LIFT: the retry re-adds instead of connecting. + const previousAddA = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + enabled = false + return previousAddA(name, cfg) + } + const outcome = await ensure("s1") + expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) + expect(h.reads).toEqual([true, false]) // two inspections + expect(outcome.kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + + test("RESIDUAL: MCP.connect's persistMcpEnabled(true) RMW rewrites the disable before the re-inspection can see it", async () => { + let enabled = true + const h = install( + [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + syncInternals.mcp!.connect = async (name) => { + h.connects.push(name) + enabled = false // the user's disable lands during the handshake (mcp/index.ts:914 createAndStore) + enabled = true // ...and connect's persistMcpEnabled(name, true) RMW (mcp/index.ts:917 → 986-988) writes over it + } + expect((await ensure("s1")).kind).toBe("reused") + expect((await ensure("s1")).kind).toBe("reused") + expect(h.reads).toEqual([true, true, true]) + expect(h.removes).toEqual([]) + }) +}) + +describe("L3 (c) — MCP.disconnect lands between the config read and the status read inside inspectEntry", () => { + test("RESIDUAL: planForEntry sees enabled+disabled → retry-connect → MCP.connect is invoked", async () => { + let enabled = true + const h = install([{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + })) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + // disconnect (prompt.ts:3004 / routes/mcp.ts:228): status → disabled, disk → enabled:false + enabled = false + return realStatus() + } + // ADAPTED ON LIFT — the residual this documented is closed. It existed + // because `MCP.connect` performed a read-modify-write of `enabled: true`, + // reverting a disable that had just landed. The retry re-adds now and writes + // no config, so nothing reverts the user's edit. + const previousAddC = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => previousAddC(name, cfg) + const outcome = await ensure("s1") + expect(h.connects, "reverted a disable by repairing through the config-writing primitive").toHaveLength(0) + // Better than the `reused` this documented, and better than a bare + // `superseded`: the re-inspection sees the disable and names it. + expect(outcome.kind).toBe("entry-disabled") + }) + + test("control: the same disconnect landing BEFORE the config read is honoured", async () => { + const h = install([{ datamate: { status: "disabled" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled: false, + })) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.connects).toEqual([]) + }) +}) + +describe("L3 (f) — the plan derived from an Inspection is held across the probes, then persist writes enabled:true", () => { + test("replace-unattributable: a disable landing during the PATH probe is persisted over, and the memo never re-checks", async () => { + // The extension's own entry: unpinned, live. Rule 1 replaces it. + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + ) + // The user disables the entry while the flow is probing `datamate --version` + // on PATH (seconds: declaredBounded up to 4s, versionOf ~1s, projectEntry). + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + onDisk = { ...onDisk, enabled: false } + return "0.7.0" + } + // persist() replaces the whole `mcp.datamate` node in the project file + // (mcp/config.ts:54-59), so a later fresh read returns OUR entry. + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // INVERTED ON LIFT. This file documents current behaviour, and the behaviour + // it documented was the defect: the plan was held across the probes and then + // persisted our `enabled: true` over a disable that had landed meanwhile, + // after which the memo read our own entry and stood forever. The guard + // re-reads intent as well as the binding now, so the write never happens. + expect(first.kind).toBe("superseded") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + expect(h.added, "installed over a disable that landed during the probes").toHaveLength(0) + + // Next turn: the memo validator reads fresh config — which is now our pinned, enabled entry. + const second = await ensure("s1") + // The next turn re-decides rather than riding a memo: it reads the disable + // and reports it by name. + expect(second.kind).toBe("entry-disabled") + // Two teardowns now, both correct: the pre-spawn detach of the unpinned + // entry, and the disabled entry's own teardown on the next turn — a disabled + // entry serves nothing, so it is not left registered. + expect(h.removes).toEqual(["datamate", "datamate"]) + }) + + test("same shape on the pinned-but-below-floor path", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + if (bin.startsWith("/opt/old")) return "0.6.3" + onDisk = { ...onDisk, enabled: false } // disable lands during the PATH probe + return "0.7.0" + } + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // INVERTED ON LIFT — same shape, same fix. Here the disable is caught by the + // pre-write world check rather than by a re-inspection, so it reports + // `superseded`; either way the write never happens, which is the property. + expect(first.kind).toBe("superseded") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + }) + + test("control: a disable that lands BEFORE the inspection is honoured on the same entry", async () => { + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio"], + enabled: false, + })) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toHaveLength(0) + }) +}) + +describe("L3 (a)/(b) — edits between the two reads inside inspectEntry (no retry)", () => { + test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + })) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("reused") + expect(h.persisted).toEqual([]) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + + test("(b) re-enable after the config read → honour-disable on the stale half, config untouched, next turn repairs", async () => { + let enabled = false + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = true + return realStatus() + } + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) + expect(["reused", "attached"]).toContain((await ensure("s1")).kind) + }) + + test("(inverted round-12) IDE adds the entry after the config read → spawn persists over it, unreported", async () => { + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }], () => onDisk) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } // IDE sync lands here + return realStatus() + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("attached") + expect((outcome as { replaced?: string }).replaced).toBeUndefined() + expect(h.persisted).toHaveLength(1) + expect(h.removes).toEqual([]) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts new file mode 100644 index 0000000000..0bd37661a4 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts @@ -0,0 +1,622 @@ +// Gate lens 5 — adversarial probes of the `settledOutcome` seam and the +// `pinnedWorkspace` parser against the precedence contract. Disposable. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + ensure, + resetForTests, + syncInternals, + pinnedWorkspace, + settledOutcome, + attributableEngine, + MAX_TRACKED_SESSIONS, + trackedSessionsForTests, + type LocalMcpConfig, + type Outcome, +} from "../../../src/altimate/workspace/engine-sync" +import { SERVING, INSTALL_HELPS } from "../../../src/altimate/workspace/engine-types" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + toolsChanged: number + restores: Array + statusQueue: Array> + tools: Record +} + +function install(opts: { + binding?: CachedBinding | null + which?: string | null + version?: string | null | ((bin: string) => string | null) + declared?: { keys: string[]; extensionKeys: string[] } | null + statuses?: Harness["statusQueue"] + tools?: Record + existing?: ExistingEntry | null +}): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + toolsChanged: 0, + restores: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + } + syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => + opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } : opts.declared + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + h.toolsChanged += 1 + } + syncInternals.persistRestore = async (_name, previous) => { + h.restores.push(previous ?? null) + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + connect: async (name) => { + h.connects.push(name) + }, + remove: async (name) => { + h.removes.push(name) + }, + tools: async () => h.tools, + } + return h +} + +const connected = { datamate: { status: "connected" } } +const never = () => new Promise(() => {}) +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)) + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +// ───────────────────────────── P1: pure synchronous read ───────────────────────────── +describe("P1 — settledOutcome is a pure synchronous read", () => { + test("is a plain function whose body has no await/then and never touches the task", () => { + expect(settledOutcome.constructor.name).toBe("Function") + const src = settledOutcome.toString() + expect(src).not.toMatch(/\bawait\b/) + expect(src).not.toMatch(/\.then\b/) + expect(src).not.toMatch(/\btask\b/) + expect(src).toMatch(/outcome/) + }) + + test("returns immediately, not a promise, while an attach is in flight on a probe that never resolves", async () => { + install({}) + syncInternals.versionOf = never + void ensure("s1") + const t0 = performance.now() + const one = settledOutcome("s1") + const dtOne = performance.now() - t0 + expect(one).toBeUndefined() + expect(one).not.toBeInstanceOf(Promise) + expect(dtOne).toBeLessThan(5) + + const t1 = performance.now() + for (let i = 0; i < 10_000; i++) settledOutcome("s1") + expect(performance.now() - t1).toBeLessThan(200) + + await tick() + expect(settledOutcome("s1")).toBeUndefined() + }) +}) + +// ───────────────────── P2: undefined means "not settled", never stale ───────────────────── +describe("P2 — undefined for in-flight AND never-attached; no premature or stale write", () => { + test("never attached → undefined; attributableEngine(undefined) → false", () => { + expect(settledOutcome("nobody")).toBeUndefined() + expect(attributableEngine(undefined)).toBe(false) + }) + + test("no outcome table has a pending/in-flight kind that a caller could mistake for a verdict", () => { + const kinds = Object.keys(SERVING).sort() + expect(kinds).toEqual( + [ + "attached", + "reused", + "disabled", + "unbound", + "engine-missing", + "engine-too-old", + "connect-failed", + "entry-disabled", + "superseded", + ].sort(), + ) + expect(Object.keys(INSTALL_HELPS).sort()).toEqual(kinds) + }) + + test("nothing is written to the memo before run() returns — probed at MCP.add and at the final notify", async () => { + const h = install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + const seen: Array = [] + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + seen.push(settledOutcome("s1")) + await add(n, c) + } + syncInternals.notify = async (t) => { + seen.push(settledOutcome("s1")) + h.toasts.push(t) + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached" }) + expect(seen).toEqual([undefined, undefined]) + expect(settledOutcome("s1")).toBe(outcome) + }) + + test("a re-link re-attach does NOT carry the previous session outcome forward while the new attach is in flight", async () => { + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, connected, connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(settledOutcome("s1")).toBe(first) + + // Project re-linked to 99; the replacement spawn hangs at the version probe. + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + syncInternals.versionOf = never + void ensure("s1") + // The dangerous reading would be `attached` (42's engine) under binding 99. + expect(settledOutcome("s1")).toBeUndefined() + await tick() + expect(settledOutcome("s1")).toBeUndefined() + expect(attributableEngine(settledOutcome("s1"))).toBe(false) + // and the 42 client was already torn down by the re-attach's rejection + expect(h.removes).toContain("datamate") + }) + + test("a concurrent second ensure for the same session while the first is in flight stays undefined until settle", async () => { + install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + let release: (v: string | null) => void = () => {} + syncInternals.versionOf = () => new Promise((r) => (release = r)) + const a = ensure("s1") + await tick(5) + const b = ensure("s1") // turn 2 while turn 1 is still probing + expect(settledOutcome("s1")).toBeUndefined() + release("0.7.0") + const [oa, ob] = await Promise.all([a, b]) + expect(oa).toMatchObject({ kind: "attached" }) + expect(ob).toBe(oa) + expect(settledOutcome("s1")).toBe(oa) + }) + + test("OBSERVATION: on every later turn the memo is replaced by a fresh entry, so the seam reads undefined during re-validation", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + const first = await ensure("s1") + expect(settledOutcome("s1")).toBe(first) + // Turn 2, same binding, engine still live → the memo path. + const t = ensure("s1") + const during = settledOutcome("s1") + const after = await t + expect(after).toBe(first) + expect(settledOutcome("s1")).toBe(first) + // Record what the window reads. `undefined` = fail-open (shadowing off) for the + // duration of the awaited re-validation; not a mis-route. + expect(during).toBeUndefined() + expect(h.added).toHaveLength(1) + }) +}) + +// ───────────────────────────── P3: allowlist {attached, reused} ───────────────────────────── +describe("P3 — the allowlist is exactly {attached, reused}", () => { + test("SERVING is true for exactly the consumer's two kinds", () => { + const serving = Object.entries(SERVING) + .filter(([, v]) => v) + .map(([k]) => k) + .sort() + expect(serving).toEqual(["attached", "reused"]) + }) + + test("the consumer's inline allowlist and attributableEngine agree on every kind", () => { + for (const kind of Object.keys(SERVING) as Array) { + const outcome = { kind } as Outcome + const consumer = outcome.kind === "attached" || outcome.kind === "reused" + expect(attributableEngine(outcome), kind).toBe(consumer) + } + }) + + test("`attached` with `replaced` set describes the NEW pinned spawn, not the displaced entry", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, // the extension's unpinned entry, live + statuses: [connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const out = settledOutcome("s1") + expect(out).toMatchObject({ kind: "attached", replaced: "datamate start-stdio" }) + // the engine serving this session is the pinned spawn; the unpinned one was closed first + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) +}) + +// ─────────────────────── P4: describes the engine serving THIS session ─────────────────────── +describe("P4 — the outcome describes the engine actually serving this session", () => { + test("`reused` is only emitted when the live entry's pin equals this binding; any other pin is replaced", async () => { + const cases: Array<{ name: string; entry: ExistingEntry; want: "reused" | "attached" }> = [ + { name: "pinned to us", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, want: "reused" }, + { name: "unpinned (extension entry)", entry: { command: "datamate", args: ["start-stdio"] }, want: "attached" }, + { name: "pinned elsewhere", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, want: "attached" }, + { name: "pinned elsewhere, = spelling", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate=7"] }, want: "attached" }, + { name: "pinned to us then overridden elsewhere (last wins)", entry: { type: "local", command: ["datamate", "--datamate", "42", "--datamate", "7"] }, want: "attached" }, + { name: "pinned elsewhere then to us (last wins)", entry: { type: "local", command: ["datamate", "--datamate", "7", "--datamate=42"] }, want: "reused" }, + { name: "connected URL", entry: { type: "remote", url: "https://api.altimate.ai/sse" }, want: "attached" }, + ] + for (const c of cases) { + resetForTests() + const h = install({ existing: c.entry, statuses: [connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure(`s-${c.name}`) + const out = settledOutcome(`s-${c.name}`) + expect(out?.kind, c.name).toBe(c.want) + if (c.want === "attached") { + expect(h.added[0]?.cfg.command, c.name).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + } else { + expect(h.added, c.name).toHaveLength(0) + } + } + }) + + test("a re-link during the REUSE lookup settles as `superseded` in the memo, not `reused`, and detaches", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "superseded" }) + expect(attributableEngine(settledOutcome("s1"))).toBe(false) + expect(h.removes).toContain("datamate") + }) + + test("a re-link after the SPAWN's add settles as `superseded` in the memo, not `attached`", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await add(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "superseded" }) + expect(h.removes).toContain("datamate") + expect(h.restores.length).toBeGreaterThan(0) + }) + + test("`superseded` is repairable: the next turn re-attaches rather than riding the memo", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, connected, {}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + let flipOnce = true + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await add(n, c) + if (flipOnce) { + flipOnce = false + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + }) + + test("a memoised `attached` is dropped when the config pin moves under it (A→B→A with another session serving B)", async () => { + let pin = "42" + install({ statuses: [{}, connected, connected, {}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", pin] }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + pin = "99" // the instance-wide client now serves B + const t = ensure("s1") + expect(settledOutcome("s1")).toBeUndefined() + const second = await t + expect(second).not.toBe(first) + }) + + test("OBSERVATION: the pin compared is the CONFIG entry's; MCP.status carries no argv, so a config-only rewrite is indistinguishable from a reconnect", async () => { + // Harness: config says pinned-to-42 and status says connected. Nothing in + // run() can tell whether the connected process was launched with that argv. + install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [connected], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "reused" }) + }) +}) + +// ───────────────────────────── P5: keyed by session ID ───────────────────────────── +describe("P5 — keyed by session ID", () => { + test("two sessions in one project hold distinct outcomes", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") // spawns → attached + await ensure("s2") // finds the persisted pinned entry live → reused + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + expect(settledOutcome("s2")).toMatchObject({ kind: "reused" }) + expect(settledOutcome("s3")).toBeUndefined() + expect(h.added).toHaveLength(1) + }) + + test("keys are session ids, not project/binding: a refused session does not overwrite a served one", async () => { + install({ statuses: [{}, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + // s2 in the same project sees the entry disabled → refuses (and tears down). + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }) + await ensure("s2") + expect(settledOutcome("s2")).toEqual({ kind: "entry-disabled" }) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + }) + + test("OBSERVATION: eviction can drop a settled outcome while the session is live (fails open)", async () => { + install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + syncInternals.resolveBinding = async () => null + for (let i = 0; i < MAX_TRACKED_SESSIONS; i++) await ensure(`other-${i}`) + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + expect(settledOutcome("s1")).toBeUndefined() + }) + + test("OBSERVATION: another session's teardown leaves this session's settled `attached` stale until its next turn", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }) + await ensure("s2") // honours the disable: removes the instance-wide client + expect(h.removes).toEqual(["datamate"]) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) // stale: client is gone + // s1's next turn re-decides + h.statusQueue = [{ datamate: { status: "disabled" } }] + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + }) +}) + +// ───────────────────────────── P6: pinnedWorkspace table ───────────────────────────── +describe("P6 — pinnedWorkspace over every argv shape", () => { + const table: Array<{ name: string; entry: unknown; want: string | null | "THROWS" }> = [ + // contract shapes + { name: "opencode argv, two tokens", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "IDE {command,args}, two tokens", entry: { command: "datamate", args: ["start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "IDE {command,args}, = spelling", entry: { command: "datamate", args: ["start-stdio", "--datamate=5"] }, want: "5" }, + { name: "opencode argv, = spelling", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate=5"] }, want: "5" }, + { name: "repeated two-token, last wins", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate", "9"] }, want: "9" }, + { name: "repeated = then two-token, last wins", entry: { type: "local", command: ["datamate", "--datamate=5", "--datamate", "9"] }, want: "9" }, + { name: "repeated two-token then =, last wins", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate=9"] }, want: "9" }, + { name: "pin split across command and args", entry: { command: ["datamate", "start-stdio", "--datamate"], args: ["5"] }, want: "5" }, + { name: "no pin", entry: { type: "local", command: ["datamate", "start-stdio"] }, want: null }, + { name: "null entry", entry: null, want: null }, + { name: "URL entry", entry: { type: "remote", url: "http://localhost:7801/sse" }, want: null }, + { name: "empty command", entry: { type: "local", command: [] }, want: null }, + // dangling / empty + { name: "--datamate as last token, no value", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate"] }, want: null }, + { name: "earlier pin then dangling --datamate (engine would refuse to start)", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate"] }, want: null }, + { name: "--datamate= empty", entry: { type: "local", command: ["datamate", "--datamate="] }, want: null }, + { name: "--datamate=5 then --datamate= (engine: last wins = empty)", entry: { type: "local", command: ["datamate", "--datamate=5", "--datamate="] }, want: null }, + // odd values + { name: "non-numeric value", entry: { type: "local", command: ["datamate", "--datamate", "abc"] }, want: "abc" }, + { name: "value that looks like a flag (commander: argument missing)", entry: { type: "local", command: ["datamate", "--datamate", "--verbose"] }, want: "--verbose" }, + { name: "quoted value inside the token", entry: { type: "local", command: ["datamate", "--datamate=\"5\""] }, want: "\"5\"" }, + { name: "value with surrounding whitespace", entry: { type: "local", command: ["datamate", "--datamate", " 5"] }, want: " 5" }, + { name: "=-value containing another =", entry: { type: "local", command: ["datamate", "--datamate=5=6"] }, want: "5=6" }, + { name: "leading-zero id", entry: { type: "local", command: ["datamate", "--datamate", "05"] }, want: "05" }, + // near-miss flag names + { name: "--datamate-id is not the pin flag", entry: { type: "local", command: ["datamate", "--datamate-id", "5"] }, want: null }, + { name: "--datamatex=5 is not the pin flag", entry: { type: "local", command: ["datamate", "--datamatex=5"] }, want: null }, + { name: "case differs (commander is case-sensitive too)", entry: { type: "local", command: ["datamate", "--DATAMATE", "5"] }, want: null }, + { name: "single-dash", entry: { type: "local", command: ["datamate", "-datamate", "5"] }, want: null }, + // the flag inside another token / shell wrappers + { name: "shell -c wrapper, whole command in one token", entry: { type: "local", command: ["sh", "-c", "datamate start-stdio --datamate 5"] }, want: null }, + { name: "cmd /c wrapper", entry: { type: "local", command: ["cmd", "/c", "datamate start-stdio --datamate 5"] }, want: null }, + { name: "IDE command string with spaces and no args", entry: { command: "datamate start-stdio --datamate 5" }, want: null }, + { name: "npx wrapper still parses the pin", entry: { type: "local", command: ["npx", "-y", "@altimateai/datamate", "start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "pin as the VALUE of another flag (--config-file --datamate 5)", entry: { type: "local", command: ["datamate", "--config-file", "--datamate", "5"] }, want: "5" }, + { name: "pin only via environment, not argv", entry: { type: "local", command: ["datamate", "start-stdio"], environment: { DATAMATE_ID: "5" } }, want: null }, + { name: "URL entry that also carries args", entry: { type: "remote", url: "http://x/sse", args: ["--datamate", "5"] }, want: "5" }, + // defensive-read shapes (merged config written by other clients) + { name: "args as a string, not an array", entry: { command: "datamate", args: "start-stdio --datamate 5" }, want: null }, + { name: "numeric token in argv (raw disk JSON)", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", 5] }, want: "THROWS" }, + { name: "null token BEFORE the last pin is never reached (scan from the end)", entry: { type: "local", command: ["datamate", null, "--datamate", "5"] }, want: "5" }, + { name: "null token AFTER the last pin throws", entry: { type: "local", command: ["datamate", "--datamate", "5", null] }, want: "THROWS" }, + { name: "numeric token AFTER the last pin throws", entry: { type: "local", command: ["datamate", "--datamate", "5", 7] }, want: "THROWS" }, + { name: "command is an object", entry: { type: "local", command: {} }, want: "THROWS" }, + ] + + for (const row of table) { + test(row.name, () => { + if (row.want === "THROWS") { + expect(() => pinnedWorkspace(row.entry as never)).toThrow() + } else { + expect(pinnedWorkspace(row.entry as never)).toBe(row.want) + } + }) + } + + test("the consumer's comparison: pin vs String(datamateId)", () => { + const pin = pinnedWorkspace({ type: "local", command: ["datamate", "--datamate", "5"] }) + expect(pin !== null && pin !== String(5)).toBe(false) + expect(pin !== null && pin !== String("5")).toBe(false) + const quoted = pinnedWorkspace({ type: "local", command: ["datamate", "--datamate=\"5\""] }) + expect(quoted !== null && quoted !== String(5)).toBe(true) // treated as pinned elsewhere + }) +}) + +// ───────────── 2d8bea2d0: the connect-retry re-inspection vs the memo ───────────── +describe("the retry re-inspects, and never writes the memo early or twice", () => { + // ADAPTED ON LIFT. These were written against `MCP.connect`, which the retry + // no longer uses: connect writes `enabled: true` into whichever config owns + // the entry, so a local repair became a global config write, and it started + // whatever MCP had retained rather than the entry the decision examined. The + // retry re-adds instead. The PROPERTIES here are unchanged — re-inspect whole, + // judge on the post-retry entry, write the memo exactly once — only the seam + // that stands in for "the retry happened" has moved from `connect` to `add`. + test("two inspections, one status per inspection, memo written exactly once, after the retry settles", async () => { + const reads: Array = [] + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + let entryReads = 0 + let statusReads = 0 + syncInternals.existingEntry = async () => { + entryReads += 1 + reads.push(settledOutcome("s1")) + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + const status = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + statusReads += 1 + reads.push(settledOutcome("s1")) + return status() + } + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + reads.push(settledOutcome("s1")) + return add(n, cfg) + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "reused" }) + expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) + expect(h.added, "the retry restarts the entry exactly once").toHaveLength(1) + expect(entryReads).toBe(2) // re-inspected whole, not status alone + expect(statusReads).toBe(2) + expect(reads.every((r) => r === undefined)).toBe(true) // nothing observable mid-run + expect(settledOutcome("s1")).toBe(outcome) + }) + + test("the pin is judged on the POST-retry entry", async () => { + // One case changed verdict when attribution moved above connectivity, and it + // changed for the better: an UNPINNED entry is no longer revived and then + // discovered to be unattributable — it is replaced without being started at + // all, so the retry never runs for it. The retry is for OUR engine. + const cases: Array<{ name: string; before: ExistingEntry; after: ExistingEntry; want: "reused" | "attached" }> = [ + { + name: "unpinned → never retried, replaced outright", + before: { type: "local", command: ["datamate", "start-stdio"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + want: "attached", + }, + { + name: "pinned 42 → unpinned", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio"] }, + want: "attached", + }, + { + name: "pinned 42 → pinned 7", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, + want: "attached", + }, + { + name: "pinned 42 → pinned 42", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + want: "reused", + }, + ] + for (const c of cases) { + resetForTests() + let retried = false + const h = install({ + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => (retried ? c.after : c.before) + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + retried = true + return add(n, cfg) + } + await ensure(`s-${c.name}`) + const out = settledOutcome(`s-${c.name}`) + expect(out?.kind, c.name).toBe(c.want) + if (c.want === "attached") { + expect(h.added.at(-1)?.cfg.command, c.name).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + } + } + }) + + test("a disable that lands during the retry is honoured on re-inspection and torn down", async () => { + let retried = false + const h = install({ + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => + retried + ? { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + : { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + retried = true + return add(n, cfg) + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted, "wrote config while honouring a disable").toHaveLength(0) + }) +}) From f5858bdec45e18b5c9c8dd39efee3dd8dc6ca3ab Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:20:16 +0800 Subject: [PATCH 39/67] test(workspace): use spies, not a module mock, for the freshness invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mock.module` is registered process-wide and cannot be unregistered, so mocking the config module from one test file took down every later file in the run that builds a real Config layer — seven unrelated agent and permission tests, none of which had anything to do with this work. Caught by running the whole suite rather than the file under change. A test file that breaks unrelated suites is worse than the gap it closes, and this one would have looked like a real regression to whoever hit it next. Restorable spies do the same job: the fake cache still only refreshes when invalidated, so staleness stays directly observable, and all three cases still fail when any invalidation is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../workspace/engine-config-freshness.test.ts | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts index 41f5aa3136..a3d0c084e0 100644 --- a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -13,39 +13,42 @@ // This file mocks `Config` with a cache that only updates when invalidated, so // a stale read is directly observable: write to the "file", read, and see // whether the write is visible. -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Config } from "../../../src/config/config" +import { existingEntry } from "../../../src/altimate/workspace/engine-config" +import { syncInternals } from "../../../src/altimate/workspace/engine-seams" +// Spies rather than a module mock. `mock.module` is registered process-wide and +// cannot be unregistered, so mocking the config module from here took down every +// later test file in the run that builds a real Config layer — a test file that +// breaks unrelated suites is worse than the gap it closes. let fileContents: { mcp?: Record } = {} let cached: { mcp?: Record } | null = null let invalidations = 0 - -mock.module("../../../src/config/config", () => ({ - Config: { - // Models the real thing: `get()` is cached per instance and does NOT see a - // write made behind it until something invalidates. - get: async () => { - if (cached === null) cached = structuredClone(fileContents) - return cached - }, - invalidate: async () => { - invalidations += 1 - cached = null - }, - }, -})) - -const { existingEntry } = await import("../../../src/altimate/workspace/engine-config") -const { syncInternals } = await import("../../../src/altimate/workspace/engine-seams") +let getSpy: ReturnType +let invalidateSpy: ReturnType beforeEach(() => { fileContents = {} cached = null invalidations = 0 + // Models the real thing: `get()` is cached per instance and does NOT see a + // write made behind it until something invalidates. + getSpy = spyOn(Config, "get").mockImplementation(async () => { + if (cached === null) cached = structuredClone(fileContents) + return cached as never + }) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => { + invalidations += 1 + cached = null + }) delete syncInternals.existingEntry delete syncInternals.freshConfig }) afterEach(() => { + getSpy.mockRestore() + invalidateSpy.mockRestore() for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] }) From 16b47ddb4c946ebc04d34d942616f6995ec29dc9 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:23:22 +0800 Subject: [PATCH 40/67] perf(workspace): probe the PATH engine once per attach, not twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths ask the same question — "is there something better on PATH than the entry we just rejected" and "what would we spawn" — and the below-floor replacement path reaches both, so replacing a pre-floor engine spawned `datamate --version` twice and paid about a second for an answer it already had. One lazy, memoised probe per attach. Not hoisted to module scope on purpose: an engine installed between sessions must still be found, and the cache lasting one attach is what keeps that true while removing the duplicate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 6b80ede36e..f05e87f437 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -347,6 +347,21 @@ async function run(): Promise { return !!now && String(now.datamateId) === workspaceId } + /** The PATH engine's version, probed at most once per attach. + * + * Probing spawns a process and takes about a second. Two paths ask the same + * question — "is there something better on PATH than the entry we just + * rejected" and "what would we spawn" — and the below-floor path reaches both, + * so a replaced pre-floor engine paid for the same answer twice. */ + let pathProbe: { bin: string | null; version: string | null } | undefined + const enginePath = async (): Promise<{ bin: string | null; version: string | null }> => { + if (!pathProbe) { + const bin = which(ENGINE_BINARY) + pathProbe = { bin, version: bin ? await versionOf(bin) : null } + } + return pathProbe + } + /** Is the world this decision was made in still the world we are mutating? * * `stillCurrent` asks only about the binding, and a mutation guarded on half @@ -663,8 +678,7 @@ async function run(): Promise { // PATH is probed HERE rather than inside the plan because probing spawns a // process: folding it into the pure decision would charge the reuse path — // the common one, run on every turn — for a question it never asks. - const onPath = which(ENGINE_BINARY) - const pathVersion = onPath ? await versionOf(onPath) : null + const { version: pathVersion } = await enginePath() if (!clearsFloor(pathVersion)) { const label = found ?? "unknown" // Rejected and irreplaceable: detach anyway. Leaving it connected would @@ -698,7 +712,7 @@ async function run(): Promise { const declaredCount = declaredKeys?.keys.length ?? 0 // Rule 2 / 3 — opportunistic use, or an offer. Never an install. - const bin = which(ENGINE_BINARY) + const { bin, version: found } = await enginePath() if (!bin) { return await refuse({ kind: "engine-missing", declared: declaredCount }, { title: "Workspace integrations unavailable", @@ -709,7 +723,6 @@ async function run(): Promise { }) } - const found = await versionOf(bin) if (!clearsFloor(found)) { const label = found ?? "unknown" return await refuse({ kind: "engine-too-old", found: label }, { From e8a164fbf2a8c229e35a7621b657a92836e422e2 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:32:25 +0800 Subject: [PATCH 41/67] fix(workspace): the single exit is the whole task, and it may have no workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both from answering a consumer's questions about this exit by reading the code rather than recalling what it used to do. The context carried no workspace identity. `Outcome` has `found` and `declared` but no workspace, so an announcement that wants to name one had only `workspaceId` — meaning anything richer would have to re-read the binding, which is the describer-and-decider split this module keeps paying for. It is a typed `RefusalContext` now, and decided refusals pass the name alongside the id. Every field on it is optional, and that is the contract rather than laziness: this exit serves exceptions as well as decisions, and an exception can happen before a binding exists. Anything downstream that wants to name a workspace has to cope with not having one. Which is how the second finding surfaced. A test for "a throw before the binding resolves" did not fail an assertion — it escaped `ensure()` as an unhandled rejection. `attachKey`, the memo re-validation and the serialization chain all run before `attachOnce`'s catch, so routing the throw path through the single exit covered `run()` and not the task around it: no outcome, no toast, and since the caller starts this fire-and-forget, silence — the one failure mode this module exists to remove. `failSafely` wraps the whole task and the duplicate catch is gone. Narrowing it back to the attach fails the new test. Not reachable in production today, since the binding read catches internally. Structural, and "not reachable today" is what the teardown finding was before it was reproduced. Worth naming because it is the third instance on this branch: a rule can be satisfied at the boundary its author was looking at rather than the boundary it names. "Exactly one exit for throws" meant `run()`'s throws because `run()` was where the work was. The check order was an artifact of the old code's block structure. A residual was called bounded because the path that disproved it was not in view. Each fix was correct exactly as far as its author's attention reached. 353 tests pass; typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 83 +++++++++++++------ .../altimate/workspace/engine-sync.test.ts | 31 +++++++ 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index f05e87f437..4a990f03fa 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -269,7 +269,26 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie * NEVER throws: "never silent" also has to mean "never relabelled". A throw here * reached the catch-all and turned a decided outcome into `connect-failed` with * a second toast, so failing to DESCRIBE a verdict silently rewrote it. */ -async function announceRefusal(outcome: Outcome, toast: Toast, context?: Record): Promise { +/** What the announcement knows about the refusal beyond the outcome itself. + * + * `Outcome` carries `found` and `declared` but no workspace identity, and the + * announcement needs one: a message that names the workspace, and anything + * keyed per workspace downstream. + * + * Every field is OPTIONAL, and that is the contract rather than laziness. This + * function is the single exit for exceptions as well as decisions, and a throw + * can happen before a binding is resolved — the flag read, the MCP handle, the + * serialization chain all precede it. A body that assumes a workspace is here + * will crash on the one path nobody writes a fixture for. When identity is + * absent the toast still fires; anything that needs to NAME a workspace must + * stay silent rather than guess at one. */ +type RefusalContext = { + workspaceId?: string + workspaceName?: string + sessionID?: string +} + +async function announceRefusal(outcome: Outcome, toast: Toast, context?: RefusalContext): Promise { try { if (installWouldHelp(outcome)) { log.info("refusal is remediable by installing the engine", { ...context, kind: outcome.kind }) @@ -479,7 +498,7 @@ async function run(): Promise { }) return { kind: "superseded" } } - await announceRefusal(outcome, toast, { workspaceId }) + await announceRefusal(outcome, toast, { workspaceId, workspaceName: binding.datamateName }) return outcome } @@ -1063,7 +1082,13 @@ export function ensure(sessionID: string): Promise { // is silently rebuilt. validated: previous?.validated, } as SessionAttach - entry.task = (async (): Promise => { + // The whole task, not just the attach. `attachKey`, the memo re-validation and + // the serialization chain all run BEFORE the attach's own catch, so a throw in + // any of them escaped `ensure` as a rejected promise: no outcome, no toast, + // and — since the caller starts this fire-and-forget — silence, which is the + // one failure mode this module exists to remove. "Exactly one exit for throws + // too" has to mean the whole task, or it names a boundary rather than a rule. + entry.task = failSafely(sessionID, async (): Promise => { const key = await attachKey() const sameWorkspace = !!previous && previous.key === key // Same workspace and the attach either succeeded or is still in flight: @@ -1101,7 +1126,7 @@ export function ensure(sessionID: string): Promise { if (previous) await previous.task.catch(() => {}) } return attachOnce(sessionID) - })() + }) entry.task.then( (outcome) => { entry.outcome = outcome @@ -1114,28 +1139,38 @@ export function ensure(sessionID: string): Promise { /** One attach, serialized against every other attach in this project, with the * outcome logged exactly once. */ +/** Run an attach task so that NOTHING escapes as a rejection. + * + * Every explicit failure branch tells the user what is unavailable and why. An + * unexpected throw must not be the single path that leaves them with neither + * tools nor an explanation: the caller starts this fire-and-forget and + * `whenAttached` returns void, so a rejection here is silence. + * + * Announced through the same exit as every decided refusal, and with NO + * workspace identity — a throw can happen before a binding exists, so anything + * downstream that wants to name a workspace has to cope with not having one. */ +async function failSafely(sessionID: string, task: () => Promise): Promise { + try { + return await task() + } catch (err) { + const error = String(err) + log.warn("workspace engine attach failed", { sessionID, err: error }) + const outcome: Outcome = { kind: "connect-failed", error } + await announceRefusal( + outcome, + { + title: "Workspace engine attach failed", + message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, + variant: "error", + }, + { sessionID }, + ) + return outcome + } +} + function attachOnce(sessionID: string): Promise { return serializeAttach(() => run()) - .catch(async (err): Promise => { - const error = String(err) - // Every explicit failure branch tells the user what is unavailable and - // why. An unexpected throw — an unwritable project config, a malformed - // one — must not be the single path that leaves them with neither tools - // nor an explanation, since the caller discards this outcome and - // `whenAttached` returns void. - log.warn("workspace engine attach failed", { err: error }) - const outcome: Outcome = { kind: "connect-failed", error } - await announceRefusal( - outcome, - { - title: "Workspace engine attach failed", - message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, - variant: "error", - }, - { sessionID }, - ) - return outcome - }) .then((outcome) => { // One line per session, whatever happened — silence is the defect this // module exists to remove, so it must not be silent about itself. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index f29aa1b4eb..8192dfe35a 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2042,3 +2042,34 @@ describe("INVARIANT — the last thing awaited before a mutation is the world ch }) } }) + +describe("INVARIANT — the single exit survives a failure with no workspace to name", () => { + test("a throw BEFORE the binding resolves still announces, exactly once", async () => { + // The refusal exit is the single exit for exceptions too, and an exception + // can happen before there is any workspace identity — the flag read, the MCP + // handle and the serialization chain all precede the binding. Anything in + // that exit that assumes a workspace will crash here, on the one path with + // no natural fixture. + const h = install({}) + syncInternals.resolveBinding = async () => { + throw new Error("credentials unavailable") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts, "a failure with no workspace to name went unannounced, or announced twice").toHaveLength(1) + expect(h.toasts[0]!.message).toContain("credentials unavailable") + // Nothing was installed, so nothing needs undoing. + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("a throw AFTER the binding resolves still announces exactly once", async () => { + const h = install({ statuses: [{}] }) + syncInternals.declared = async () => { + throw new Error("allowlist exploded") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts).toHaveLength(1) + }) +}) From 726759c6a1d00c62d510184d2df965f6ecb4c9d9 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:52:00 +0800 Subject: [PATCH 42/67] fix(workspace): a failed read is never an answer, and the write checks last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guard that fails open is worse than no guard: its presence is what stops the next person looking. Three sites did, all of them mine, and all found by running a defect class from a sibling branch across this one instead of agreeing with it. The world guard caught its own intent read to `null`, and `null` does not look disabled — so a config read that merely FAILED was read as permission to write. The guard added to stop us overwriting a user's disable was defeated by the read breaking rather than by the timing window it was built for. It fails closed now: if intent cannot be confirmed, nothing is written. The memo validator returned true on a failed probe, so a disabled entry or a moved pin rode a transient error on the path every turn after the first takes. The comment defending that was wrong about the cost — returning false discards nothing, it re-decides under the lock and either attaches or refuses through the single exit. The guard also reads in the other order now, binding first and intent last, so the only thing standing between confirming intent and the write is the write's own read. And the write does its own check: `addMcpToConfig` replaces the whole entry node, so a disable landing in that last gap was not raced but erased — the post-install guard then read the file WE had just written and found nothing to undo. Invisible rather than reverted. `persist` re-reads the node it is about to replace and refuses, which closes it at the only point where nothing can intervene. Two consequences worth naming. The retry's re-add now takes the same whole- world guard: it starts a process, and a disable forbids starting one as surely as it forbids writing. And the guard reports WHICH half of the world moved, so a mid-decision disable is answered `entry-disabled` rather than a generic `superseded` — "you switched this off" is a more useful answer than "something changed", and we only have it at the point the guard runs. The adjacency invariant is updated with the guard rather than around it: a WRITE must sit on binding-then-intent, a TEARDOWN only on the binding, since intent neither authorises nor forbids stopping a client. Every part is proven by mutation: catching intent back to null, returning true from the validator, and dropping persist's re-read each fail a named test. The gate's real-file proof is lifted and re-staged — its trigger targeted a window that the new read order removed. 365 tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 21 +- .../src/altimate/workspace/engine-seams.ts | 2 +- .../src/altimate/workspace/engine-sync.ts | 103 +++++- .../workspace/engine-sync-gate-l1.test.ts | 16 +- .../altimate/workspace/engine-sync.test.ts | 76 ++++- .../altimate/workspace/gate-l3-r2.test.ts | 318 ++++++++++++++++++ .../altimate/workspace/l3-snapshot.test.ts | 27 +- .../workspace/l5-seam-contract.test.ts | 5 +- 8 files changed, 530 insertions(+), 38 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 3838654f54..995c015ceb 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -20,9 +20,25 @@ export async function projectConfigPath(): Promise { return resolveConfigPath(projectRoot()) } -export async function persist(name: string, cfg: LocalMcpConfig, configPath?: string): Promise { - if (syncInternals.persist) return syncInternals.persist(name, cfg) +/** Why a write did not happen. `written` is the ordinary case. */ +export type PersistResult = "written" | "disabled" + +export async function persist(name: string, cfg: LocalMcpConfig, configPath?: string): Promise { + if (syncInternals.persist) return (await syncInternals.persist(name, cfg)) ?? "written" configPath = configPath ?? (await resolveConfigPath(projectRoot())) + // The LAST read before the write, and the only check a caller's guard cannot + // do for us. `addMcpToConfig` replaces the whole `mcp.` node, so a + // disable that lands after the caller's guard and before this write is not + // merely raced — it is erased, and the post-install check then reads the file + // WE just wrote and finds nothing to undo. Invisible rather than reverted. + // + // Refusing here closes it at the only point where nothing can intervene: the + // caller turns this into `entry-disabled` and no config is touched at all. + const onDisk = (await readMcpEntryFromDisk(name, configPath)) as ExistingEntry | undefined + if (onDisk?.enabled === false) { + log.info("refusing to write over an entry that is disabled on disk", { name }) + return "disabled" + } await addMcpToConfig(name, cfg, configPath) // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file // write that does not touch that cache — so without this, every later @@ -33,6 +49,7 @@ export async function persist(name: string, cfg: LocalMcpConfig, configPath?: st await Config.invalidate().catch((err) => { log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) }) + return "written" } /** The module's ONLY path to config, and it is always fresh. diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index d42ca119a9..eb6ca3c5bd 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -24,7 +24,7 @@ export const syncInternals: { spawned?: (name: string) => Promise tools: () => Promise> } - persist?: (name: string, cfg: LocalMcpConfig) => Promise + persist?: (name: string, cfg: LocalMcpConfig) => Promise projectConfigPath?: () => Promise persistRestore?: (name: string, previous: ExistingEntry | null) => Promise projectEntry?: () => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 4a990f03fa..79d70be7f0 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -394,15 +394,54 @@ async function run(): Promise { * Both reads live in one function so nothing can be inserted between them, and * this is the LAST await before any mutation. The invariant is not "no * mutation on a stale binding" but "no mutation on a stale world". */ - const worldUnchanged = async (): Promise => { - const entryNow = await existingEntry(DATAMATE_KEY).catch(() => null) + const worldUnchanged = async (): Promise<"ok" | "moved" | "disabled"> => { + // Binding FIRST, intent LAST, so the only thing standing between the intent + // check and the write is the write's own read — which does the check again, + // at the one point nothing can intervene. + if (!(await stillCurrent())) return "moved" + let entryNow: ExistingEntry | null + try { + entryNow = await existingEntry(DATAMATE_KEY) + } catch (err) { + // Fails CLOSED. The previous version caught this to `null`, and `null` + // does not look disabled — so a config read that merely FAILED was read as + // permission to write, and the guard was defeated by the read breaking + // rather than by the timing window it was built for. If intent cannot be + // confirmed, nothing is written. + log.warn("could not confirm intent before mutating; abandoning the attach", { + workspaceId, + err: String(err), + }) + return "moved" + } if (entryNow?.enabled === false) { log.info("intent changed while deciding; not writing over a disable", { workspaceId }) - return false + return "disabled" } - return await stillCurrent() + return "ok" } + /** The refusal a mid-decision disable earns. + * + * Reported as `entry-disabled` rather than `superseded` because the guard + * knows WHICH half of the world moved, and the two mean different things to a + * user: one says "something changed, try again", the other says "you switched + * this off, and it stays off". Collapsing them would throw away the more + * useful answer at the point we finally have it. */ + const refuseDisabled = (): Promise => + refuse( + { kind: "entry-disabled" }, + { + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }, + { reason: "the entry is disabled" }, + false, + ) + /** Stop serving an entry we have judged untrustworthy for this workspace. * * Runtime-only (`MCP.remove`): closes the client and drops it from the tool @@ -540,7 +579,13 @@ async function run(): Promise { // handed. Reviving becomes the same operation as spawning, which is the // real win — the retry stops being a special path with special rules. const revive: LocalMcpConfig = { type: "local", command: commandArgv(inspection.entry), enabled: true } - if (!(await stillCurrent())) return { kind: "superseded" } + // The whole world, not just the binding: this starts a process, and a + // disable that landed since the inspection forbids starting it just as + // surely as it forbids writing config. The plan was derived from a snapshot + // taken before a status read; re-confirm both halves before acting on it. + const beforeRevive = await worldUnchanged() + if (beforeRevive === "disabled") return await refuseDisabled() + if (beforeRevive !== "ok") return { kind: "superseded" } await client.add(DATAMATE_KEY, revive).catch((err) => { log.warn("could not restart the engine entry", { err: String(err), workspaceId }) }) @@ -789,11 +834,12 @@ async function run(): Promise { }) } const configPath = await projectConfigPath().catch(() => undefined) - if (!(await worldUnchanged())) { - // Re-linked or disabled while we were probing. Installing now would attach a - // workspace this session has left, or overwrite a disable that landed while - // we were deciding — and would win by arriving first. - log.info("abandoning attach; the world changed before the engine was installed", { workspaceId }) + const beforeInstall = await worldUnchanged() + if (beforeInstall === "disabled") return await refuseDisabled() + if (beforeInstall !== "ok") { + // Re-linked while we were probing. Installing now would attach a workspace + // this session has left, and would win by arriving first. + log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) return { kind: "superseded" } } @@ -817,8 +863,18 @@ async function run(): Promise { // undo itself. One rule, one place, and exits nobody anticipated are covered // by construction rather than by review. let committed = false + // Distinct from `committed`: whether anything was actually written or + // registered. A write refused at the last moment left nothing behind, and the + // undo must not "restore" over a config it never touched. + let installed = false try { - await persist(DATAMATE_KEY, cfg, configPath) + if ((await persist(DATAMATE_KEY, cfg, configPath)) === "disabled") { + // A disable landed between our guard and the write, and the write saw it. + // Nothing was written and nothing registered. + log.info("write refused: the entry is disabled on disk", { workspaceId }) + return await refuseDisabled() + } + installed = true await client.add(DATAMATE_KEY, cfg) // Rule 4 — a failed local engine is reported, never routed around. @@ -858,9 +914,15 @@ async function run(): Promise { // Late rather than early on purpose: the check is only meaningful at the // last moment before we announce and answer, because everything before that // is still revocable. The undo itself now belongs to the region. - if (!(await worldUnchanged())) { - log.info("the world changed before the attach could be reported; undoing what we installed", { workspaceId }) - return { kind: "superseded" } + const afterInstall = await worldUnchanged() + if (afterInstall !== "ok") { + log.info("the world changed before the attach could be reported; undoing what we installed", { + workspaceId, + why: afterInstall, + }) + // Either way the install is undone by the region. A disable reports itself + // so the user learns their edit took effect, rather than a generic race. + return afterInstall === "disabled" ? await refuseDisabled() : { kind: "superseded" } } // Ours, and staying. Answer BEFORE announcing: `announceToolsChanged` and @@ -891,7 +953,7 @@ async function run(): Promise { }) return outcome } finally { - if (!committed) { + if (installed && !committed) { await undoInstall(projectBefore).catch((err) => { log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) }) @@ -988,8 +1050,15 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom if (record) record.validated = command return true } catch (err) { - log.warn("could not re-probe the engine attribution; keeping the cached attach", { err: String(err) }) - return true + // Fails CLOSED, and the earlier comment claiming otherwise was wrong about + // the cost. Returning true serves a memo whose world could not be confirmed + // — a disabled entry or a moved pin rides a transient probe error, on the + // path taken by every turn after the first. Returning false does not discard + // anything: it routes back through `run()`, which re-inspects under the + // per-project lock and either attaches or refuses through the single exit, + // with no mutation. A failed read is never an answer. + log.warn("could not confirm the cached attach; re-deciding rather than serving it", { err: String(err) }) + return false } } diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts index 1982937bd7..1137047679 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts @@ -131,10 +131,22 @@ describe("T1 — the last awaited seam before every mutation is the binding read let j = i - 1 while (j >= 0 && MUTATIONS.has(trace[j])) j-- const before = trace[j] + const beforeThat = trace[j - 1] // persist→add is the one sanctioned adjacency (persist has no seam of its own - // to re-read after); everything else must sit directly on a binding read. + // to re-read after); everything else must sit directly on the world check. if (trace[i] === "add" && trace[i - 1] === "persist") continue - if (before !== "resolveBinding") out.push(`${trace[i]} at #${i} follows ${before ?? ""}`) + // ADAPTED ON LIFT: the world check is now TWO reads in a fixed order — + // binding, then intent — because a guard that confirms only the binding is + // a guard on half the world. Intent goes last so the only thing between + // confirming it and the write is the write's own read of the node it + // replaces, which checks again where nothing can intervene. + // A WRITE needs the whole world (intent forbids creating anything); a + // TEARDOWN needs only the binding, since intent neither authorises nor + // forbids stopping a client. + const isWrite = trace[i] === "persist" || trace[i] === "add" + if (isWrite && before === "existingEntry" && beforeThat === "resolveBinding") continue + if (!isWrite && before === "resolveBinding") continue + out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) } return out } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 8192dfe35a..5adaf66d1a 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1953,7 +1953,7 @@ describe("INVARIANT — never write what you cannot undo, and never stop waiting }) }) -describe("INVARIANT — the last thing awaited before a mutation is the world check", () => { +describe("INVARIANT — the last thing awaited before a mutation is the whole world check", () => { // The mechanical form of "every await after a guard belongs to the guard's // problem". Individual tests flip a binding at one seam and check one // outcome; that only ever catches the seam someone thought of, which is why @@ -2033,10 +2033,25 @@ describe("INVARIANT — the last thing awaited before a mutation is the world ch const offenders: string[] = [] trace.forEach((step, i) => { if (!MUTATIONS.has(step)) return + // The world check is TWO reads in a fixed order — binding, then intent — + // so the adjacency to assert is the pair, not one seam. Intent goes last + // deliberately: the only thing left between confirming intent and + // writing is the write's own read of the node it replaces. const before = trace[i - 1] - if (before === "resolveBinding") return + const beforeThat = trace[i - 2] if (step === "add" && before === "persist") return // one commit, one guard - offenders.push(`${step} followed ${before ?? "(nothing)"}`) + // A WRITE needs the whole world: `enabled: false` forbids creating + // anything, so intent is part of the question. + if (step === "persist" || step === "add") { + if (before === "existingEntry" && beforeThat === "resolveBinding") return + } else if (before === "resolveBinding") { + // A TEARDOWN only needs the binding. Intent neither authorises nor + // forbids stopping a client: a disabled entry is torn down regardless, + // and the only question a foreign entry raises is whether it belongs + // to the workspace we are now bound to. + return + } + offenders.push(`${step} followed ${beforeThat ?? "(nothing)"} -> ${before ?? "(nothing)"}`) }) expect(offenders, `${name}: ${offenders.join("; ")} — trace was ${trace.join(" -> ")}`).toEqual([]) }) @@ -2073,3 +2088,58 @@ describe("INVARIANT — the single exit survives a failure with no workspace to expect(h.toasts).toHaveLength(1) }) }) + +describe("INVARIANT #13 — a failed read is never an answer", () => { + // The class: a failure to LEARN something, encoded as a confident fact. It is + // invisible to every other invariant here, because they all test what happens + // when a read succeeds — ordering, completeness, staleness, adjacency. None + // asks what a function does when the read throws. + // + // A guard that fails open is worse than no guard, because its presence is what + // stops the next person looking. + + test("a guard whose intent read THROWS writes nothing", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + // The inspection succeeds; the guard's confirming read fails. + if (reads > 1) throw new Error("EIO: config unreadable") + return good(name) + } + const outcome = await ensure("s1") + expect(h.persisted, "wrote config without confirming the user still wants it").toHaveLength(0) + expect(h.added, "started an engine without confirming the user still wants it").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a memo whose validating read THROWS is re-decided, not served", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + + const good = syncInternals.existingEntry! + let failNext = true + syncInternals.existingEntry = async (name: string) => { + if (failNext) { + failNext = false + throw new Error("EIO: config unreadable") + } + return good(name) + } + // Serving the memo would mean answering with a world we could not confirm — + // a disabled entry or a moved pin riding a transient probe error, on the + // path every turn after the first takes. Re-deciding costs an inspection. + const first = settledOutcome("s1") + const second = await ensure("s1") + // The property is that the memo was not SERVED, not that the re-decision + // reaches a different verdict — re-deciding may well conclude reuse, and + // that is fine, because it concluded it from a world it could actually read. + // Identity is what separates "handed back the cached answer" from "worked it + // out again". + expect(second, "served a memo whose world could not be confirmed").not.toBe(first) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts new file mode 100644 index 0000000000..cdd6b725de --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts @@ -0,0 +1,318 @@ +// L3 round-2 experiments against 16b47ddb4. Not part of the suite. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdtempSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" +import { Config } from "../../../src/config/config" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry + bindingCalls: number +} + +function install( + statuses: H["statusQueue"], + entry: () => ExistingEntry | null, + opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}, +): H { + const h: H = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + statusQueue: statuses, + reads: [], + spawnedNow: opts.spawned, + bindingCalls: 0, + } + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + connect: async (name) => { + h.connects.push(name) + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] +}) + +describe("R1 — the world guard's intent read vs the write: REAL persist on a real file", () => { + // No persist seam: the production `persist` → `addMcpToConfig` runs against a + // temp file. Only `Config.invalidate` is spied to a no-op (no instance here). + let dir: string + let file: string + let invalidateSpy: ReturnType + const unpinned: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + + beforeEach(async () => { + dir = mkdtempSync(path.join(tmpdir(), "l3r2-")) + file = path.join(dir, "altimate-code.json") + await addMcpToConfig("datamate", unpinned as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => invalidateSpy.mockRestore()) + + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function realInstall(landDisableAtIntentReads: number) { + let landed = false + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { + realPersist: true, + }) + syncInternals.projectConfigPath = async () => file + // RE-STAGED ON LIFT. The guard now reads the binding FIRST and intent LAST, + // so "after the guard's intent read and before the write" is no longer a + // window that a later binding read can be used to land in — the intent read + // IS the last thing before persist. The disable therefore lands at the end + // of that read, which is the narrowest and only remaining gap, and exactly + // the one persist's own re-read of the node it replaces exists to close. + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (!landed && h.reads.length === landDisableAtIntentReads) { + landed = true + const now = (await diskEntry())! + await addMcpToConfig("datamate", { ...now, enabled: false } as never, file) + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + return h + } + + test("disable lands between the guard's intent read and persist's write → written over, memo stands", async () => { + // reads: inspect#1 (1), worldUnchanged intent (2) → land during the binding read that follows. + const h = realInstall(2) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 outcome:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // INVERTED ON LIFT — this is the finding, and it is closed at the syscall. + // No guard the caller can hold covers the gap between confirming intent and + // the write itself, so `persist` re-reads the node it is about to replace + // and refuses when that node says disabled. The write never happens, and + // because it never happens the post-install check no longer reads a file we + // wrote and conclude there is nothing to undo. + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled, "the user's disable was written over").toBe(false) + expect(after?.command, "disk still holds the USER's entry").toEqual(["datamate", "start-stdio"]) + expect(h.added, "installed over a disable").toHaveLength(0) + // Next turn re-decides from disk and reaches the same answer. + const second = await ensure("s1") + expect(second.kind).toBe("entry-disabled") + expect(readFileSync(file, "utf8")).toContain('"enabled": false') + }) + + test("control: the same disable landing BEFORE the guard's intent read is caught → superseded, disk keeps it", async () => { + // reads: inspect#1 (1) → land during detachRejected's binding read (before worldUnchanged reads intent). + const h = realInstall(1) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 control:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // ADAPTED ON LIFT: still caught, and now reported by name. The guard knows + // WHICH half of the world moved, and "you switched this off" is a more + // useful answer than "something changed, try again". + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled).toBe(false) + expect(after?.command).toEqual(["datamate", "start-stdio"]) + expect(h.added).toHaveLength(0) + }) +}) + +describe("R2 — retry path: a disable between inspection #1 and the revive add", () => { + test("spawns then tears down; writes nothing", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + const realBinding = syncInternals.resolveBinding! + syncInternals.resolveBinding = async () => { + // the retry's stillCurrent() — after inspection #1 read intent + if (h.reads.length === 1) enabled = false + return realBinding() + } + const outcome = await ensure("s1") + // INVERTED ON LIFT: the revive guard checks the whole world now, so the + // entry is never started. Start-then-tear-down was the shape this branch + // already judged worse than never-started. + expect(outcome.kind).toBe("entry-disabled") + expect(h.added, "revived the entry the user had just disabled").toHaveLength(0) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(0) + expect(h.connects).toHaveLength(0) + }) +}) + +describe("R3 — an IDE rewrite between persist and add", () => { + test("this turn: attached with disk unpinned; next turn: our own engine is replaced", async () => { + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: true } + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + // IDE sync lands after our persist, before our add + if (h.persisted.length === 1 && h.added.length === 0) onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + return prevAdd(n, c) + } + const first = await ensure("s1") + expect(first.kind).toBe("attached") + expect((onDisk as unknown as ExistingEntry)?.command).toEqual(["datamate", "start-stdio"]) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + const second = await ensure("s1") + console.log("R3 second:", JSON.stringify(second), "removes:", h.removes, "persisted:", h.persisted.length) + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) // tore down OUR correctly pinned engine because the file says unpinned + expect(h.persisted).toHaveLength(2) + }) +}) + +describe("R4 — the spawned record: absent, stale, and cross-process", () => { + test("(i) bootstrap failed (no record), config pinned to us → revived via add, never connect", async () => { + const h = install( + [{ datamate: { status: "failed", error: "spawn ENOENT" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + ) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.connects).toHaveLength(0) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("(ii) dead child, record still says pinned 5 (onclose does not clear it), file re-pinned to 42 → replaced, not revived", async () => { + const h = install( + [{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(1) + }) + + test("(iii) cross-process: B bootstrapped pinned 5, A re-pinned the shared file to 7, B now bound to 7 → B replaces its own client", async () => { + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added[0]!.cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "7"]) + }) + + test("(iv) record present but the file entry was removed by another process → plan is spawn; runtime ignored", async () => { + const h = install([{}, { datamate: { status: "connected" } }], () => null, { + spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + }) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("attached") + expect((outcome as { replaced?: string }).replaced).toBeUndefined() // the 5-engine's replacement is unreported + expect(h.removes).toHaveLength(0) // storeClient closes the previous client inside MCP; this module never says so + }) + + test("(v) memo path: record diverges from file after attach (file re-pinned to 7 under a 42 binding) → memo invalid, re-decided", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }, + ) + const first = await ensure("s1") + expect(first.kind).toBe("reused") + onDisk = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true } + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) + }) +}) + +describe("R5 — (a)/(b) between the two reads inside inspectEntry, unchanged from round 1", () => { + test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + }), { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("reused") + expect(h.persisted).toEqual([]) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts index dcea0eaef5..b92548ece2 100644 --- a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts +++ b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts @@ -89,7 +89,7 @@ describe("L3 (a') — a disable lands INSIDE the retry's connect window", () => } const outcome = await ensure("s1") expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) - expect(h.reads).toEqual([true, false]) // two inspections + expect(h.reads, "inspection, pre-revive guard, re-inspection").toEqual([true, true, false]) // two inspections expect(outcome.kind).toBe("entry-disabled") expect(h.removes).toEqual(["datamate"]) }) @@ -112,7 +112,9 @@ describe("L3 (a') — a disable lands INSIDE the retry's connect window", () => } expect((await ensure("s1")).kind).toBe("reused") expect((await ensure("s1")).kind).toBe("reused") - expect(h.reads).toEqual([true, true, true]) + // One more read than before: the pre-revive guard now confirms intent as + // well as the binding before starting anything. + expect(h.reads).toEqual([true, true, true, true]) expect(h.removes).toEqual([]) }) }) @@ -181,8 +183,10 @@ describe("L3 (f) — the plan derived from an Inspection is held across the prob // it documented was the defect: the plan was held across the probes and then // persisted our `enabled: true` over a disable that had landed meanwhile, // after which the memo read our own entry and stood forever. The guard - // re-reads intent as well as the binding now, so the write never happens. - expect(first.kind).toBe("superseded") + // re-reads intent as well as the binding now, so the write never happens — + // and it reports WHICH half moved, so the user learns their edit took + // effect rather than being told about a generic race. + expect(first.kind).toBe("entry-disabled") expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) expect(h.added, "installed over a disable that landed during the probes").toHaveLength(0) @@ -191,10 +195,11 @@ describe("L3 (f) — the plan derived from an Inspection is held across the prob // The next turn re-decides rather than riding a memo: it reads the disable // and reports it by name. expect(second.kind).toBe("entry-disabled") - // Two teardowns now, both correct: the pre-spawn detach of the unpinned - // entry, and the disabled entry's own teardown on the next turn — a disabled - // entry serves nothing, so it is not left registered. - expect(h.removes).toEqual(["datamate", "datamate"]) + // Three teardowns now, all correct: the pre-spawn detach of the unpinned + // entry, the disabled entry's teardown when the guard catches the disable + // before the write, and its teardown again on the next turn. A disabled + // entry serves nothing, so it is never left registered. + expect(h.removes).toEqual(["datamate", "datamate", "datamate"]) }) test("same shape on the pinned-but-below-floor path", async () => { @@ -211,10 +216,8 @@ describe("L3 (f) — the plan derived from an Inspection is held across the prob onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } } const first = await ensure("s1") - // INVERTED ON LIFT — same shape, same fix. Here the disable is caught by the - // pre-write world check rather than by a re-inspection, so it reports - // `superseded`; either way the write never happens, which is the property. - expect(first.kind).toBe("superseded") + // INVERTED ON LIFT — same shape, same fix. + expect(first.kind).toBe("entry-disabled") expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) }) diff --git a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts index 0bd37661a4..9162657cf1 100644 --- a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts @@ -540,7 +540,10 @@ describe("the retry re-inspects, and never writes the memo early or twice", () = expect(outcome).toMatchObject({ kind: "reused" }) expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) expect(h.added, "the retry restarts the entry exactly once").toHaveLength(1) - expect(entryReads).toBe(2) // re-inspected whole, not status alone + // Three: the inspection, the pre-revive world check's intent read, and the + // re-inspection. The middle one is the guard confirming intent immediately + // before starting a process — a mutation, and mutations re-read. + expect(entryReads).toBe(3) expect(statusReads).toBe(2) expect(reads.every((r) => r === undefined)).toBe(true) // nothing observable mid-run expect(settledOutcome("s1")).toBe(outcome) From 392cc5f63b8701c6bd821232db4b65051463ff6a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 09:55:46 +0800 Subject: [PATCH 43/67] feat(mcp): removed means the runtime forgets it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MCP.remove` closed the client and dropped it from the client and status maps, but left `s.config[name]` behind — and `getMcpConfig` prefers that over the config file. So a removed entry outlived the client it described: `status()` kept synthesising `{status: "disabled"}` from it for the rest of the process, and `connect` re-spawned whatever it held rather than what the file now says. I previously recorded this as belonging to the MCP owners on the grounds that our own exposure had gone once the attach flow stopped calling `connect`. That boundary was wrong, and the gate showed why: the exposure did not disappear, it moved to the other callers. After any remove of ours, the synthesised status makes the next attach read a configured-but-dead entry, and the manager tool and `/mcp enable` both validate against the file and then call `connect`, which prefers the retained entry — so they can report success for one workspace while spawning another's pin. "Removed means the runtime forgets it" is the right semantics for every server key, not a special case for ours: a caller that has torn a client down and then asks about the key should be told nothing is there, not handed a description of the thing it just removed. The spawn record follows the same rule in two more places it was missing: `disconnect` and the child's own `onclose`. It answers "what IS running", and a disabled key or an exited child runs nothing. Neither was wrong today — nothing read it on those paths — but leaving it correct only by accident is how the original defect happened. Kept to those fields, as a shared primitive change should be. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- packages/opencode/src/mcp/index.ts | 23 ++++++++++++++++--- packages/opencode/test/mcp/lifecycle.test.ts | 24 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7fd77ab2e6..d1ac3fc8ef 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -709,6 +709,10 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + // altimate_change — the child exited, so nothing is running under this + // key. The spawn record answers "what IS running" and must not outlive + // the process it describes. + delete s.spawned[name] s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -961,6 +965,10 @@ export const layer = Layer.effect( // altimate_change end yield* closeClient(s, name) delete s.clients[name] + // altimate_change — nothing is running under this key now, so the spawn + // record must not survive it either: it answers "what IS running", and a + // disabled key runs nothing. + delete s.spawned[name] s.status[name] = { status: "disabled" } // altimate_change start — telemetry + persist enabled:false so disable survives restarts Telemetry.track({ @@ -985,10 +993,19 @@ export const layer = Layer.effect( yield* closeClient(s, name) delete s.clients[name] delete s.status[name] - // altimate_change — nothing is running under this key any more, so nothing - // was spawned under it. Leaving the record behind makes a later caller - // believe a torn-down engine is still serving. + // altimate_change start — nothing is running under this key any more, so + // neither the spawn record nor the runtime config may outlive it. + // + // `s.config` is what `getMcpConfig` prefers over the file, so a stale entry + // here outlives the client it described: `status()` keeps synthesising + // "disabled" from it for the rest of the process, and `connect` re-spawns + // whatever it holds rather than what the file now says. "Removed" has to + // mean the runtime forgets it, for every server key — a caller that has + // torn a client down and then asks about the key should be told nothing is + // there, not handed the description of the thing it just removed. delete s.spawned[name] + delete s.config[name] + // altimate_change end yield* events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore) }) // altimate_change end diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index f3e7115225..05736109dc 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1289,3 +1289,27 @@ it.instance( ), ) // altimate_change end + +// altimate_change start — "removed means the runtime forgets it" +it.instance( + "removing a client clears the runtime config, not just the client", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // `getMcpConfig` prefers the runtime config over the file, so an entry + // left behind here outlives the client it described: `status()` keeps + // synthesising "disabled" from it, and `connect` re-spawns what it holds + // rather than what the file now says. A caller that removed a client and + // then asks about the key must be told nothing is there. + lastCreatedClientName = "forget" + yield* mcp.add("forget", { type: "local", command: ["echo", "one"] }) + expect(Object.keys(yield* mcp.status())).toContain("forget") + + yield* mcp.remove("forget") + expect(Object.keys(yield* mcp.status()), "the key survived its own removal").not.toContain("forget") + expect(yield* mcp.spawned("forget")).toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end From 557609ae1f63b1f1b31b532986e5160f30072111 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:08:42 +0800 Subject: [PATCH 44/67] fix(workspace): enforce the failed-read rule at the layer that answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule "a failed read is never an answer" was stated one layer above the layer that broke it. `existingEntry` caught its own failure and returned `null` — and every caller reads `null` as "there is no entry": the guard as "nothing forbids this write", the inspection as "nothing here, spawn". So the fail-closed guard added last commit could never fire in production, and the test that was meant to prove it threw from the seam, which sits above the swallow. A rule enforced at one layer and undone at the layer below is not enforced, and a test written at the same layer as the rule cannot see it. The reader propagates now, as `projectEntry` already did, and callers decide. An unreadable configuration is refused by name — one label and one toast wherever the failure lands, rather than a silent `superseded` at the guard and a `connect-failed` at the inspection for the identical failure. The rest of this commit is the same rule in the other places it was not held: - A probe that throws is a version we could not read, which already counts as below the floor. Propagating instead reached the catch-all before any teardown, so a persistent probe failure toasted every turn while the rejected client stayed registered and serving. - An undo that could not be confirmed is an actionable failure. `superseded` is silent because normally nothing is left behind; when the restore fails, our pin IS left behind and bootstraps on the next restart. The restore reports, and the failure raises exactly one toast naming the file. - A throwing success announcement no longer rewrites the outcome. Those two awaits carry no no-throw guarantee at the seam — only the production bodies happen to swallow — so a throw reported `connect-failed` for an engine that is attached, connected and persisted. I claimed this could not happen; the claim was about the production bodies and I asserted it about the region. - A throw landing after a re-link is the same silent `superseded` as every other refusal for a workspace the project has left. - `String(err)` on a null-prototype value throws inside the catch that exists to stop throws. Two ordering fixes: in-region refusals undo BEFORE they announce, since `refuse` states that order for every other exit and the announcement is a substitution point; and the below-floor REPLACEABLE teardown is binding-independent like its irreplaceable sibling, which only the latter had. The write's disabled check moved inside `addMcpToConfig`, decided on the same text the write modifies — a check that reads the file separately from the write has checked a different read. My previous claim that this closed the window "at the syscall" was overstated and the comment now says so: one read and one write to one file is not atomic, and that residual is named rather than papered over. The adjacency invariant encodes the teardown split rather than working around it, and exercises both halves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 62 ++++-- .../src/altimate/workspace/engine-seams.ts | 2 +- .../src/altimate/workspace/engine-sync.ts | 197 +++++++++++++++--- packages/opencode/src/mcp/config.ts | 22 +- .../workspace/engine-config-freshness.test.ts | 26 +++ .../workspace/engine-sync-gate-l1.test.ts | 15 +- .../altimate/workspace/engine-sync.test.ts | 119 ++++++++++- .../altimate/workspace/gate-l4-attack.test.ts | 12 +- 8 files changed, 396 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 995c015ceb..f0d6356ccb 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -26,20 +26,22 @@ export type PersistResult = "written" | "disabled" export async function persist(name: string, cfg: LocalMcpConfig, configPath?: string): Promise { if (syncInternals.persist) return (await syncInternals.persist(name, cfg)) ?? "written" configPath = configPath ?? (await resolveConfigPath(projectRoot())) - // The LAST read before the write, and the only check a caller's guard cannot - // do for us. `addMcpToConfig` replaces the whole `mcp.` node, so a - // disable that lands after the caller's guard and before this write is not - // merely raced — it is erased, and the post-install check then reads the file - // WE just wrote and finds nothing to undo. Invisible rather than reverted. + // The check travels WITH the write rather than preceding it. `addMcpToConfig` + // replaces the whole `mcp.` node, so a disable landing after a caller's + // guard is not merely raced — it is erased, and the post-install check then + // reads the file WE just wrote and finds nothing to undo. Invisible rather + // than reverted. // - // Refusing here closes it at the only point where nothing can intervene: the - // caller turns this into `entry-disabled` and no config is touched at all. - const onDisk = (await readMcpEntryFromDisk(name, configPath)) as ExistingEntry | undefined - if (onDisk?.enabled === false) { + // It is decided on the same text the write modifies, which is as close as this + // can be got: a check that reads the file separately from the write has + // checked a different read. It does NOT make the window vanish — one read and + // one write to one file is not atomic, and a disable landing between the read + // and the `write` syscall is still lost. That residual is named on the PR + // rather than papered over; closing it needs write-then-verify. + if ((await addMcpToConfig(name, cfg, configPath, { refuseIfDisabled: true })) === null) { log.info("refusing to write over an entry that is disabled on disk", { name }) return "disabled" } - await addMcpToConfig(name, cfg, configPath) // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file // write that does not touch that cache — so without this, every later // `existingEntry()` in this process still sees the pre-write config. That is @@ -98,25 +100,41 @@ export async function projectEntry(): Promise { * and MCP bootstraps every enabled entry, so a restart before the next attach * would start the workspace we just walked away from. Removing the runtime * client is only half of undoing an attach. */ -export async function persistRestore(name: string, previous: ExistingEntry | null): Promise { - if (syncInternals.persistRestore) return syncInternals.persistRestore(name, previous) +export async function persistRestore( + name: string, + previous: ExistingEntry | null, + configPath?: string, +): Promise<"restored" | "failed"> { + if (syncInternals.persistRestore) return (await syncInternals.persistRestore(name, previous)) ?? "restored" try { - const configPath = await resolveConfigPath(projectRoot()) - if (previous) await addMcpToConfig(name, previous as never, configPath) - else await removeMcpFromConfig(name, configPath) + // The SAME path the write used, not a fresh resolution: re-resolving can + // pick a different file than the one we wrote to, in which case the undo + // edits a config we never touched and leaves the one we did. + const target = configPath ?? (await resolveConfigPath(projectRoot())) + if (previous) await addMcpToConfig(name, previous as never, target) + else await removeMcpFromConfig(name, target) await Config.invalidate().catch(() => undefined) + return "restored" } catch (err) { + // Reported, not swallowed. An undo that could not be confirmed leaves our + // pin on disk, and MCP bootstraps every enabled entry — so the next restart + // starts the workspace this attach walked away from. That is an actionable + // failure, and the caller can only tell the user about it if it is told. log.warn("could not restore the config after a superseded attach", { name, err: String(err) }) + return "failed" } } export async function existingEntry(name: string): Promise { if (syncInternals.existingEntry) return syncInternals.existingEntry(name) - try { - const cfg = await freshConfig() - return cfg.mcp?.[name] ?? null - } catch (err) { - log.warn("could not read merged MCP config", { name, err: String(err) }) - return null - } + // THROWS rather than returning null, for the same reason `projectEntry` does: + // `null` already means "there is no entry", and every caller acts on that — + // the guard reads it as "nothing forbids this write", the inspection plans it + // as "nothing here, spawn". Swallowing here made the fail-closed guard one + // layer above UNREACHABLE: the guard's own catch could never fire, because the + // failure had already been converted into a confident answer beneath it. + // + // A rule enforced at one layer and undone at the layer below is not enforced. + const cfg = await freshConfig() + return cfg.mcp?.[name] ?? null } diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index eb6ca3c5bd..8f635f55f3 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -26,7 +26,7 @@ export const syncInternals: { } persist?: (name: string, cfg: LocalMcpConfig) => Promise projectConfigPath?: () => Promise - persistRestore?: (name: string, previous: ExistingEntry | null) => Promise + persistRestore?: (name: string, previous: ExistingEntry | null) => Promise projectEntry?: () => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 79d70be7f0..f472e78192 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -376,7 +376,18 @@ async function run(): Promise { const enginePath = async (): Promise<{ bin: string | null; version: string | null }> => { if (!pathProbe) { const bin = which(ENGINE_BINARY) - pathProbe = { bin, version: bin ? await versionOf(bin) : null } + let version: string | null = null + try { + version = bin ? await versionOf(bin) : null + } catch (err) { + // Same rule as the entry probe: unreadable is below the floor, not a + // reason to abandon the turn to the catch-all. + log.warn("could not probe the PATH engine version; treating it as unreadable", { + workspaceId, + err: String(err), + }) + } + pathProbe = { bin, version } } return pathProbe } @@ -394,7 +405,7 @@ async function run(): Promise { * Both reads live in one function so nothing can be inserted between them, and * this is the LAST await before any mutation. The invariant is not "no * mutation on a stale binding" but "no mutation on a stale world". */ - const worldUnchanged = async (): Promise<"ok" | "moved" | "disabled"> => { + const worldUnchanged = async (): Promise<"ok" | "moved" | "disabled" | "unreadable"> => { // Binding FIRST, intent LAST, so the only thing standing between the intent // check and the write is the write's own read — which does the check again, // at the one point nothing can intervene. @@ -412,7 +423,10 @@ async function run(): Promise { workspaceId, err: String(err), }) - return "moved" + // NOT "moved". The same failure reaching the inspection is reported to the + // user; reporting it here as a silent binding-move would give one failure + // two labels and two signal counts depending only on which read hit it. + return "unreadable" } if (entryNow?.enabled === false) { log.info("intent changed while deciding; not writing over a disable", { workspaceId }) @@ -421,6 +435,21 @@ async function run(): Promise { return "ok" } + /** The refusal an unreadable configuration earns. + * + * One failure, one label, wherever it lands: the reader propagates rather than + * inventing an answer, so both the inspection and the pre-write guard reach + * this. Nothing is written on the way here. */ + const refuseUnreadable = (why: string): Promise => + refuse({ kind: "connect-failed", error: `configuration unreadable: ${why}` }, { + title: "Workspace engine not attached", + message: + `Could not read this project's MCP configuration, so the engine was not attached — acting on a ` + + `configuration we cannot read risks overwriting your own "${DATAMATE_KEY}" entry. Integration tools ` + + `are unavailable until it can be read.`, + variant: "error", + }) + /** The refusal a mid-decision disable earns. * * Reported as `entry-disabled` rather than `superseded` because the guard @@ -492,12 +521,11 @@ async function run(): Promise { * Restoring the merged value writes a copy of a global entry into the project, * which is a permanent override shadowing every later global change — undoing * a write is only correct if it restores what that write replaced. */ - const undoInstall = async (projectBefore: ExistingEntry | null): Promise => { + const undoInstall = async (projectBefore: ExistingEntry | null): Promise<"restored" | "failed"> => { await client.remove(DATAMATE_KEY).catch((err) => { log.warn("could not remove the superseded engine", { err: String(err) }) }) - await persistRestore(DATAMATE_KEY, projectBefore) - return { kind: "superseded" } + return await persistRestore(DATAMATE_KEY, projectBefore, configPath) } /** The single exit for every refusal. @@ -558,7 +586,15 @@ async function run(): Promise { // IDE added after the cache warmed would otherwise be missing from status // entirely — the entry check would never run and our managed entry would be // persisted straight over theirs. - let inspection = await inspectEntry() + let inspection: Inspection + try { + inspection = await inspectEntry() + } catch (err) { + // Planning on a configuration we could not read means planning "there is + // nothing here", which is a spawn — straight over whatever is actually + // there. + return await refuseUnreadable(String(err)) + } let plan = planForEntry(inspection, workspaceId, false) if (plan.act === "retry-connect") { @@ -585,6 +621,7 @@ async function run(): Promise { // taken before a status read; re-confirm both halves before acting on it. const beforeRevive = await worldUnchanged() if (beforeRevive === "disabled") return await refuseDisabled() + if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeRevive !== "ok") return { kind: "superseded" } await client.add(DATAMATE_KEY, revive).catch((err) => { log.warn("could not restart the engine entry", { err: String(err), workspaceId }) @@ -683,7 +720,23 @@ async function run(): Promise { } if (plan.act === "check-version") { - const found = await engineVersionOf(entry) + // A probe that THROWS is a version we could not read, which `clearsFloor` + // already treats as below the floor — an engine that cannot say what it is + // cannot be shown to lock its pin. Letting it propagate instead sent the + // turn to the catch-all BEFORE any teardown, so a persistent probe failure + // toasted every single turn while the rejected client stayed registered and + // serving: the advice-versus-registration split this module exists to close. + // Read as unreadable, it is detached and refused once, and the memo holds. + let found: string | null + try { + found = await engineVersionOf(entry) + } catch (err) { + log.warn("could not probe the entry's engine version; treating it as unreadable", { + workspaceId, + err: String(err), + }) + found = null + } if (clearsFloor(found)) { // Rule 5 applies to a reused engine too. A running engine that lost an // integration — a connection deleted, a restart that dropped it — serves @@ -765,7 +818,12 @@ async function run(): Promise { found, pathVersion, }) - await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }) + // Binding-INDEPENDENT, exactly like its irreplaceable sibling: an engine + // below the floor serves nobody correctly, whatever the project is bound to + // now. Only this branch kept the default, so a re-link during the version + // probes skipped the teardown and left a too-old client connected and + // serving while the outcome said `superseded` — silently. + await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }, false) } // Bounded: this lookup is reporting only, but it runs BEFORE the engine is @@ -836,6 +894,7 @@ async function run(): Promise { const configPath = await projectConfigPath().catch(() => undefined) const beforeInstall = await worldUnchanged() if (beforeInstall === "disabled") return await refuseDisabled() + if (beforeInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeInstall !== "ok") { // Re-linked while we were probing. Installing now would attach a workspace // this session has left, and would win by arriving first. @@ -867,6 +926,45 @@ async function run(): Promise { // registered. A write refused at the last moment left nothing behind, and the // undo must not "restore" over a config it never touched. let installed = false + let undone = false + /** Give back both halves, once, before anything else happens. + * + * In-region refusals used to announce and let the `finally` tear down + * afterwards, which inverts the rule `refuse` states for every other exit: + * stop serving first, explain second. It is harmless while the announcement + * is a toast and a failed client exports nothing — but the announcement is a + * substitution point, and a body that waits on a person would leave a failed + * engine's registration and its pin outliving the dialog, with a restart + * inside it bootstrapping the entry we had already decided against. + * + * Idempotent, so the `finally` stays as a backstop for exits nobody wrote. */ + const undoNow = async (): Promise => { + if (!installed || undone) return + undone = true + const restored = await undoInstall(projectBefore).catch((err) => { + log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) + return "failed" as const + }) + if (restored === "failed") { + // An undo that could not be confirmed is an actionable failure, not a + // quiet one. Our pin is still on disk and MCP bootstraps every enabled + // entry, so the next restart starts the workspace this attach walked away + // from — and nothing else will ever mention it. `superseded` stays silent + // only when there is genuinely nothing left behind. + await announceRefusal( + { kind: "connect-failed", error: "restore failed" }, + { + title: "Workspace engine config left behind", + message: + `The engine entry for workspace "${binding.datamateName}" was installed and then abandoned, but the ` + + `previous "${DATAMATE_KEY}" entry could not be restored${configPath ? ` in ${configPath}` : ""}. ` + + `That pin is still on disk and will start on the next restart; edit or remove it to be sure.`, + variant: "error", + }, + { workspaceId, workspaceName: binding.datamateName }, + ) + } + } try { if ((await persist(DATAMATE_KEY, cfg, configPath)) === "disabled") { // A disable landed between our guard and the write, and the write saw it. @@ -881,6 +979,8 @@ async function run(): Promise { const after = (await client.status())[DATAMATE_KEY] if (after?.status !== "connected") { const error = after?.error ?? after?.status ?? "not connected" + // Undo BEFORE announcing — see `undoNow`. + await undoNow() // `which` rather than the error string: "the engine failed to start" and // "there is no engine" are different situations with different remedies, // and only the second is fixed by installing one. Reading ENOENT out of a @@ -922,7 +1022,10 @@ async function run(): Promise { }) // Either way the install is undone by the region. A disable reports itself // so the user learns their edit took effect, rather than a generic race. - return afterInstall === "disabled" ? await refuseDisabled() : { kind: "superseded" } + await undoNow() + if (afterInstall === "disabled") return await refuseDisabled() + if (afterInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") + return { kind: "superseded" } } // Ours, and staying. Answer BEFORE announcing: `announceToolsChanged` and @@ -939,24 +1042,51 @@ async function run(): Promise { log.info("attached workspace engine", { workspaceId, available, declared: declaredCount, missing, replaced }) // Announce it so a turn that had already given up waiting still learns the - // tools arrived. - await announceToolsChanged() - await notify({ - title: `Workspace "${binding.datamateName}" connected`, - message: - (declaredKeys - ? `${available} of ${declaredCount} declared integration tools available.` - : `${available} integration tools available.`) + - describeMissing(missing) + - replacedNote, - variant: missing.length > 0 ? "warning" : "success", - }) + // tools arrived — and never let announcing change what happened. + // + // These two awaits carry no no-throw guarantee at the seam; only the + // production bodies happen to swallow, and the region did not encode that + // dependency. A throw here escaped to the catch-all and reported + // `connect-failed` for an engine that is attached, connected and persisted + // — the single toast telling the user the attach failed while the tools are + // in fact there. Describing an outcome must never rewrite it, on the success + // path exactly as on the refusal path. + try { + await announceToolsChanged() + await notify({ + title: `Workspace "${binding.datamateName}" connected`, + message: + (declaredKeys + ? `${available} of ${declaredCount} declared integration tools available.` + : `${available} integration tools available.`) + + describeMissing(missing) + + replacedNote, + variant: missing.length > 0 ? "warning" : "success", + }) + } catch (err) { + log.warn("could not announce the attach; the engine is attached regardless", { + workspaceId, + err: String(err), + }) + } return outcome - } finally { - if (installed && !committed) { - await undoInstall(projectBefore).catch((err) => { - log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) + } catch (err) { + // Undo first, then decide how to report. A throw that lands after a re-link + // is the same situation as any other refusal for a workspace the project has + // left: answering names the wrong workspace and toasting is worse. The + // catch-all announces every throw it sees, so this one must not reach it. + await undoNow() + if (!(await stillCurrent())) { + log.info("attach threw after the binding moved; not answering for the old workspace", { + workspaceId, + err: String(err), }) + return { kind: "superseded" } + } + throw err + } finally { + if (!committed) { + await undoNow() } } } @@ -1218,11 +1348,24 @@ export function ensure(sessionID: string): Promise { * Announced through the same exit as every decided refusal, and with NO * workspace identity — a throw can happen before a binding exists, so anything * downstream that wants to name a workspace has to cope with not having one. */ +/** `String(err)` on a value with a null prototype throws INSIDE the catch, and + * the task rejects after all — the one remaining route to a session whose + * outcome never settles and whose await rejects into the prompt loop. Nothing in + * this codebase throws such a value; the cost of being sure is three lines. */ +function describeThrown(err: unknown): string { + if (err instanceof Error) return err.message + try { + return String(err) + } catch { + return typeof err + } +} + async function failSafely(sessionID: string, task: () => Promise): Promise { try { return await task() } catch (err) { - const error = String(err) + const error = describeThrown(err) log.warn("workspace engine attach failed", { sessionID, err: error }) const outcome: Outcome = { kind: "connect-failed", error } await announceRefusal( diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index cccbc89f9d..dc97761d8a 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -32,7 +32,18 @@ export async function resolveConfigPath(baseDir: string, global = false) { return candidates[0] } -export async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) { +export async function addMcpToConfig( + name: string, + mcpConfig: ConfigMCPV1.Info, + configPath: string, + // altimate_change — refuse to replace a node that is switched off, decided on + // the SAME text this call is about to modify. A caller that reads the file + // itself and then calls this one has checked a different read than the write + // uses, so a disable landing between the two is replaced wholesale rather than + // honoured. One read, one decision, is the only version of this check that + // means anything. + opts?: { refuseIfDisabled?: boolean }, +) { let text = "{}" if (await Filesystem.exists(configPath)) { text = await Filesystem.readText(configPath) @@ -51,6 +62,15 @@ export async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, } } + // altimate_change start — see `opts.refuseIfDisabled` + if (opts?.refuseIfDisabled) { + const current = parse(text, [], { allowTrailingComma: true }) as + | { mcp?: Record } + | undefined + if (current?.mcp?.[name]?.enabled === false) return null + } + // altimate_change end + const edits = modify(text, ["mcp", name], mcpConfig, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts index a3d0c084e0..e4cbc52b85 100644 --- a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -83,3 +83,29 @@ describe("INVARIANT — a config read observes writes made behind it", () => { expect(invalidations).toBe(2) }) }) + +describe("INVARIANT #13 at the reader — a failed read propagates, never becomes null", () => { + test("a config read that throws does not arrive at the caller as 'there is no entry'", async () => { + // The layer that matters. A guard above this one was written to fail closed + // on a throwing intent read — and could never fire, because this reader + // caught the throw and returned `null`, which every caller reads as "there + // is no entry": the guard as "nothing forbids this write", the inspection as + // "nothing here, spawn". A rule enforced at one layer and undone at the + // layer below is not enforced. + // + // Asserted HERE rather than through a stubbed seam, because a seam-level + // test cannot see a swallow that happens beneath the seam — which is exactly + // why the defect survived the invariant that was supposed to state it. + getSpy.mockImplementation(async () => { + throw new Error("EIO: config unreadable") + }) + await expect(existingEntry("datamate")).rejects.toThrow("EIO") + }) + + test("a genuinely absent entry is still null, not an error", async () => { + // The distinction is the whole point: absent and unreadable must stay + // different answers, or the caller cannot act differently on them. + fileContents = { mcp: {} } + expect(await existingEntry("datamate")).toBeNull() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts index 1137047679..037aeb9b2a 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts @@ -123,7 +123,15 @@ afterEach(() => { describe("T1 — the last awaited seam before every mutation is the binding read", () => { const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) - function violations(trace: string[]): string[] { + /** Which teardowns in a scenario are binding-DEPENDENT. + * + * The split is the point: a teardown that undoes what this attach created, or + * that stops a disabled or below-floor engine, is right whatever the project + * is bound to now — requiring a binding read before those would assert the + * opposite of what they are for. Only acting on a pre-existing entry we did + * not create depends on the binding. Scenarios declare which kind they + * exercise, because the trace cannot tell them apart. */ + function violations(trace: string[], removesAreBindingDependent = true): string[] { const out: string[] = [] for (let i = 0; i < trace.length; i++) { if (!MUTATIONS.has(trace[i])) continue @@ -145,6 +153,7 @@ describe("T1 — the last awaited seam before every mutation is the binding read // forbids stopping a client. const isWrite = trace[i] === "persist" || trace[i] === "add" if (isWrite && before === "existingEntry" && beforeThat === "resolveBinding") continue + if (!isWrite && !removesAreBindingDependent) continue if (!isWrite && before === "resolveBinding") continue out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) } @@ -167,6 +176,8 @@ describe("T1 — the last awaited seam before every mutation is the binding read expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) }) + // Its teardown is binding-INDEPENDENT: an engine below the floor serves + // nobody correctly whatever is bound now. test("pinned-but-below-floor, PATH newer", async () => { const h = install({ existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, @@ -175,7 +186,7 @@ describe("T1 — the last awaited seam before every mutation is the binding read tools: { datamate_dbt_build_model: 1 }, }) await ensure("s1") - expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + expect(violations(h.trace, false), h.trace.join(" > ")).toEqual([]) }) test("retry-connect of a down command entry", async () => { diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 5adaf66d1a..9abf9771e4 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2016,6 +2016,17 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo tools: { datamate_dbt_build_model: 1 }, }, ], + [ + // Its teardown is binding-INDEPENDENT and therefore exempt: a below-floor + // engine serves nobody correctly whatever is bound now. + "replacing an engine below the floor", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin: string) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }, + ], [ "replacing an unpinned entry", { @@ -2026,8 +2037,16 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo ], ] + // Scenarios declare whether their teardowns are binding-DEPENDENT, because the + // trace cannot tell them apart: undoing what this attach created, and stopping + // a disabled or below-floor engine, are right whatever the project is bound to + // now, so requiring a binding read before those would assert the opposite of + // what they are for. + const bindingIndependent = new Set(["replacing an engine below the floor"]) + for (const [name, opts] of scenarios) { test(`${name}: every mutation is preceded by the world check`, async () => { + const bindingDependentRemoves = !bindingIndependent.has(name) const { trace } = traced(opts) await ensure("s1") const offenders: string[] = [] @@ -2044,7 +2063,7 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo // anything, so intent is part of the question. if (step === "persist" || step === "add") { if (before === "existingEntry" && beforeThat === "resolveBinding") return - } else if (before === "resolveBinding") { + } else if (!bindingDependentRemoves || before === "resolveBinding") { // A TEARDOWN only needs the binding. Intent neither authorises nor // forbids stopping a client: a disabled entry is torn down regardless, // and the only question a foreign entry raises is whether it belongs @@ -2111,7 +2130,13 @@ describe("INVARIANT #13 — a failed read is never an answer", () => { const outcome = await ensure("s1") expect(h.persisted, "wrote config without confirming the user still wants it").toHaveLength(0) expect(h.added, "started an engine without confirming the user still wants it").toHaveLength(0) - expect(outcome.kind).toBe("superseded") + // Reported, not silent, and reported the SAME way wherever the failure lands + // — the identical failure reaching the inspection is told to the user, so + // labelling this one a silent binding-move would give one failure two labels + // and two signal counts depending only on which read hit it. + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts, "an unreadable configuration was handled silently").toHaveLength(1) + expect(h.toasts[0]!.message).toContain("Could not read") }) test("a memo whose validating read THROWS is re-decided, not served", async () => { @@ -2143,3 +2168,93 @@ describe("INVARIANT #13 — a failed read is never an answer", () => { expect(second, "served a memo whose world could not be confirmed").not.toBe(first) }) }) + +describe("INVARIANT — announcing never changes what happened", () => { + test("a throwing success announcement leaves the engine attached and installed", async () => { + // The two announce awaits carry no no-throw guarantee at the seam; only the + // production bodies happen to swallow, and the region did not encode that. + // A throw here reported `connect-failed` for an engine that is attached, + // connected and persisted — the single toast telling the user the attach + // failed while the tools are in fact there. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.toolsChanged = async () => { + throw new Error("event bus exploded") + } + const outcome = await ensure("s1") + expect(outcome.kind, "a failed announcement rewrote a successful attach").toBe("attached") + expect(h.removes, "a failed announcement undid a live attach").toHaveLength(0) + expect(h.added, "the engine was not installed").toHaveLength(1) + }) + + test("an undo that could not be confirmed is an actionable failure, not a silent one", async () => { + // `superseded` is silent because normally nothing is left behind. When the + // restore fails, our pin IS left behind and MCP bootstraps every enabled + // entry — so the next restart starts the workspace this attach walked away + // from, and nothing else will ever mention it. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.persistRestore = async () => { + h.restores.push(null) + return "failed" + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts, "left our pin on disk and said nothing about it").toHaveLength(1) + expect(h.toasts[0]!.message, "did not say what was left behind or where").toContain("datamate") + }) +}) + +describe("INVARIANT — a rejected engine is detached even when the rejection is a failure to know", () => { + test("a probe that THROWS detaches and refuses once, rather than toasting every turn", async () => { + // Letting the probe's throw propagate reached the catch-all BEFORE any + // teardown, so a persistent failure produced a toast on every turn while the + // rejected client stayed registered and serving — the outcome is advice, the + // registration is what the model sees. + const h = install({ + existing: { type: "local", command: ["/opt/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }], + which: null, + }) + syncInternals.versionOf = async () => { + throw new Error("EACCES: cannot exec") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("engine-too-old") + expect(h.removes, "left a rejected engine registered and serving").toContain("datamate") + expect(h.toasts).toHaveLength(1) + + // And the memo holds the refusal rather than re-refusing every turn. + const second = await ensure("s1") + expect(second.kind).toBe("engine-too-old") + }) + + test("a re-link during the version probes still detaches a below-floor engine", async () => { + // Binding-INDEPENDENT: an engine below the floor serves nobody correctly, + // whatever the project is bound to now. This branch kept the default and so + // skipped its teardown on a re-link, leaving a too-old client connected. + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const previousVersion = syncInternals.versionOf! + syncInternals.versionOf = async (bin: string) => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return previousVersion(bin) + } + await ensure("s1") + expect(h.removes, "a below-floor engine survived a re-link still connected").toContain("datamate") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts index 17b97bb0fc..eda8ebc0bc 100644 --- a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts @@ -172,10 +172,14 @@ describe("E — a throw after install bypasses undoInstall entirely", () => { syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } syncInternals.mcp!.tools = async () => { throw new Error("tools listing exploded") } const outcome = await ensure("s1") - // ADAPTED ON LIFT: a throw no longer unwinds past the undo — the region is - // shaped so any non-attached exit, including one nobody wrote, gives back - // both halves. - expect(outcome).toMatchObject({ kind: "connect-failed" }) + // ADAPTED ON LIFT, twice. A throw no longer unwinds past the undo — the + // region gives back both halves on any non-attached exit, including one + // nobody wrote. And because this throw lands AFTER a re-link, it is now the + // same silent `superseded` as every other refusal for a workspace the + // project has left: answering would name the wrong workspace, and toasting + // about it would be worse. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.toasts, "announced a failure for the workspace the project had left").toHaveLength(0) expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) expect(h.removes, "a throw left the client registered").toContain("datamate") expect(h.restores, "a throw left our pin on disk").toHaveLength(1) From 167966b63332702b63d6c35e2f470618988a3c0d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:19:45 +0800 Subject: [PATCH 45/67] fix(workspace): the undo obeys the world, and the consumer asks the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Restore what the write replaced" stops being right the moment the user edits the thing we wrote. Between the install and the undo there is a whole engine boot, and a disable landing in that window lands on OUR entry — so restoring the pre-install state deleted the edit they had just made, and the next turn, finding no entry at all, spawned and re-enabled. Round 4 arriving through the undo path. The undo re-reads at undo time and never undoes a disable: the same rule as the guard, applied to the undo's own write. The manager tool judged attribution from config alone, so during a re-pin it reported "already connected via datamate (N tools)" about a process serving another workspace. It gets the runtime's vote now, from the same spawn record the attach flow uses — the fix landing in the flow and not in its consumer was the round-15 shape surviving one layer out. A config path that cannot be resolved no longer falls back to resolving it again inside the write: two independent guesses about which file we touched is how an undo edits a config we never wrote. And the session key is recomputed after the awaited memo validation, so a re-link landing inside it no longer files a fresh attach under the workspace key it started with. Test hygiene, all of it removing claims the suite could not actually check: The `connect` seam is gone, so "the flow never calls the config-writing primitive" is now a compile-time guarantee rather than a set of scenarios asserting it was not called — a `@ts-expect-error` fails the build if the member returns. Two lifted tests that staged their scenarios by hooking that seam are deleted rather than left with hooks that can never fire, and two that had been weakened on lift are restored to assert what happened rather than what did not. Invariant #13 becomes a property over the seam list: every seam made to throw, asserting no mutation on a failed read, a settled outcome rather than a rejected promise, and at most one signal. Its depth limit is written into the file, because it is the limit that hid the original defect — throwing from a seam cannot see a reader swallowing beneath it, and restoring that swallow leaves the whole property green. The layer beneath is covered where it lives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../opencode/src/altimate/tools/datamate.ts | 13 +- .../src/altimate/workspace/engine-probes.ts | 1 - .../src/altimate/workspace/engine-seams.ts | 1 - .../src/altimate/workspace/engine-sync.ts | 50 ++++- .../workspace/engine-sync-gate-l1.test.ts | 4 - .../altimate/workspace/engine-sync.test.ts | 182 ++++++++++++------ .../altimate/workspace/gate-l2-repro.test.ts | 1 - .../altimate/workspace/gate-l2-repro2.test.ts | 1 - .../altimate/workspace/gate-l3-r2.test.ts | 3 - .../altimate/workspace/gate-l4-attack.test.ts | 20 +- .../altimate/workspace/l3-snapshot.test.ts | 72 +------ .../workspace/l5-seam-contract.test.ts | 3 - 12 files changed, 201 insertions(+), 150 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 547428dca3..4da3813fd9 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -273,11 +273,22 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // tools — and its credentials. A pin for a different workspace is replaced, // which is what the user asked for by naming a datamate explicitly. const configuredEntry = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) - const pinnedElsewhere = isPinnedToOtherWorkspace(configuredEntry, args.datamate_id) + // Attribution is a claim about the RUNNING engine, so the running engine + // gets a vote here too. The config says what should run; MCP's spawn + // record says what IS running, and they diverge while a re-pin is in + // flight — during which this branch reported "already connected via + // datamate (N tools)" about a process serving a different workspace's + // data, under this workspace's name. Judging on the config alone is the + // same defect the attach flow was fixed for, surviving in its consumer. + const runningEntry = await MCP.spawned(DATAMATE_KEY).catch(() => undefined) + const pinnedElsewhere = + isPinnedToOtherWorkspace(configuredEntry, args.datamate_id) || + (!!runningEntry && isPinnedToOtherWorkspace(runningEntry, args.datamate_id)) if (pinnedElsewhere) { log.info("handleAdd: existing entry is pinned to another workspace; replacing", { serverName: DATAMATE_KEY, pinnedTo: pinnedWorkspace((configuredEntry ?? null) as never), + runningPinnedTo: pinnedWorkspace((runningEntry ?? null) as never), requested: args.datamate_id, }) } diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index e94fa26b61..e06e628669 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -83,7 +83,6 @@ export function mcp() { syncInternals.mcp ?? { status: () => MCP.status() as Promise, add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), - connect: (name: string) => MCP.connect(name), remove: (name: string) => MCP.remove(name), spawned: (name: string) => MCP.spawned(name) as Promise, tools: () => MCP.tools() as Promise>, diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 8f635f55f3..477a0d790e 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -19,7 +19,6 @@ export const syncInternals: { mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig) => Promise - connect: (name: string) => Promise remove: (name: string) => Promise spawned?: (name: string) => Promise tools: () => Promise> diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index f472e78192..3909f7c0aa 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -525,6 +525,31 @@ async function run(): Promise { await client.remove(DATAMATE_KEY).catch((err) => { log.warn("could not remove the superseded engine", { err: String(err) }) }) + // "Restore what the write replaced" stops being right the moment the user + // edits the thing we wrote. Between our install and this undo there is a + // whole engine boot, and a disable landing in that window lands on OUR + // entry — so restoring the pre-install state deletes the edit they just + // made, and the next turn, seeing no entry at all, spawns and re-enables. + // Round 4 arriving through the undo path. + // + // The same rule as the guard, applied to the undo's own write: no mutation + // on a stale world. Read at undo time, and never undo a disable. + let now: ExistingEntry | null = null + try { + now = await projectEntry() + } catch (err) { + log.warn("could not read the project entry before undoing; restoring what we replaced", { + workspaceId, + err: String(err), + }) + } + if (now?.enabled === false) { + log.info("the entry was disabled while we held it; keeping the disable rather than undoing it", { + workspaceId, + }) + const keep = projectBefore ? ({ ...projectBefore, enabled: false } as ExistingEntry) : now + return await persistRestore(DATAMATE_KEY, keep, configPath) + } return await persistRestore(DATAMATE_KEY, projectBefore, configPath) } @@ -891,7 +916,16 @@ async function run(): Promise { variant: "error", }) } - const configPath = await projectConfigPath().catch(() => undefined) + let configPath: string + try { + configPath = await projectConfigPath() + } catch (err) { + // Falling back to persist's own resolution would write to a path we could + // not resolve here, which the undo then re-resolves independently — two + // guesses about which file we touched. If we cannot say where we would + // write, we do not write. + return await refuseUnreadable(`config path could not be resolved: ${String(err)}`) + } const beforeInstall = await worldUnchanged() if (beforeInstall === "disabled") return await refuseDisabled() if (beforeInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") @@ -1306,16 +1340,22 @@ export function ensure(sessionID: string): Promise { if (reusable && (await attachKeyWorkspace()) === boundTo) return previous!.task log.info("cached attach is no longer connected; re-attaching", { sessionID }) } - entry.key = key - if (sameWorkspace) { + // Recomputed AFTER the awaited validation above: a re-link landing inside it + // would otherwise file this fresh attach under the workspace key it started + // with, and the turn would drop its wait for an attach that is no longer the + // one it needs. Self-healing next turn, but a turn is what this exists to + // save. + const settledKey = await attachKey() + entry.key = settledKey + if (settledKey === key && sameWorkspace) { // Re-probing a repairable failure. Do NOT re-arm the wait: this runs on // every turn, and a retry that blocks would charge each one the full cap // (a `connect-failed` retry can sit in MCP's 30s connect budget). The // repaired engine's tools arrive over `tools/list_changed` instead. entry.waitTimedOut = true } else { - // The binding changed under this session. A fresh attach gets a fresh wait - // budget — the previous one was spent on a different workspace's engine. + // The binding changed under this session (or changed while we validated). + // A fresh attach gets a fresh wait budget — the previous one was spent on a different workspace's engine. entry.waitTimedOut = false // Serialize against the attach being superseded. Both tasks end in // `MCP.add`, and whichever completes LAST owns the runtime client, so a diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts index 037aeb9b2a..a50c8cd016 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts @@ -90,10 +90,6 @@ function install(opts: { seam("add") h.added.push({ name, cfg }) }, - connect: async (name) => { - seam("connect") - h.connects.push(name) - }, remove: async (name) => { seam("remove") h.removes.push(name) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 9abf9771e4..b59eb88874 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -118,9 +118,6 @@ function install(opts: { h.added.push({ name, cfg }) h.spawnedNow = cfg as ExistingEntry }, - connect: async (name) => { - h.connects.push(name) - }, remove: async (name) => { h.removes.push(name) h.spawnedNow = undefined @@ -1795,64 +1792,22 @@ describe("INVARIANT — the entry decision is ordered by authority and cannot aw }) }) -describe("INVARIANT — the attach flow never writes config from a repair", () => { - // `MCP.connect` persists `enabled: true` into whichever config owns the entry. - // For an IDE-written global entry that is merely down, repairing it locally - // would therefore write global config — and if a disable landed during the - // connect window, that disable is destroyed on disk with nothing to repair it, - // because every later read says enabled. Round 4 closed the `enabled: false` - // half of this; the `enabled: true` half lived on in the retry. +describe("INVARIANT — the config-writing repair primitive is unreachable", () => { + // `MCP.connect` persists `enabled: true` into whichever config owns the entry, + // so repairing a down IDE-written global entry wrote global config from a + // local decision — and a disable landing in its window was destroyed on disk + // with nothing to repair it. The flow revives with `add`, which writes nothing. // - // The flow revives with `add`, which starts a process and writes nothing. This - // asserts the primitive is never reached, on every path that could reach it. - const scenarios: Array<[string, Parameters[0]]> = [ - [ - "ours and down", - { - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }, - ], - [ - "ours and down, staying down", - { - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "x" } }], - }, - ], - [ - "disabled while connected", - { - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, - statuses: [{ datamate: { status: "connected" } }], - }, - ], - [ - "pinned elsewhere and down", - { - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] }, - statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }, - ], - [ - "a dead URL entry", - { - existing: { type: "remote", url: "http://localhost:7801/sse" }, - statuses: [{ datamate: { status: "failed" } }, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }, - ], - ] - - for (const [name, opts] of scenarios) { - test(`no config-writing repair: ${name}`, async () => { - const h = install(opts) - await ensure("s1") - expect(h.connects, `${name} repaired the entry with the config-writing primitive`).toHaveLength(0) - }) - } + // This used to be a set of scenarios asserting the primitive was not CALLED. + // It is now asserted at compile time instead, which is strictly stronger: the + // seam does not carry `connect` at all, so a future call cannot be written. + // The `@ts-expect-error` is the test — if someone puts the member back, it + // becomes unused and the build fails. + test("the seam does not expose it, so it cannot be called", () => { + const seam = syncInternals.mcp + // @ts-expect-error `connect` is deliberately absent from the MCP seam. + expect(seam?.connect).toBeUndefined() + }) }) describe("INVARIANT — attribution asks the running engine, not only the config", () => { @@ -1969,7 +1924,7 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo // below-floor engine is torn down whatever is bound, so requiring a binding // read before those would assert the opposite of what they are for. The // scenarios below exercise only paths whose mutations are binding-dependent. - const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) + const MUTATIONS = new Set(["persist", "add", "remove", "persistRestore"]) function traced(opts: Parameters[0]) { const h = install(opts) @@ -2001,7 +1956,6 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo spawned: m.spawned ? wrapRead("spawned", m.spawned) : undefined, add: wrapMutation("add", m.add), remove: wrapMutation("remove", m.remove), - connect: wrapMutation("connect", m.connect), } return { h, trace } } @@ -2258,3 +2212,107 @@ describe("INVARIANT — a rejected engine is detached even when the rejection is expect(h.removes, "a below-floor engine survived a re-link still connected").toContain("datamate") }) }) + +describe("INVARIANT — the undo obeys the world it undoes into", () => { + test("a disable that lands while we hold the entry is kept, not undone", async () => { + // Between the install and the undo there is a whole engine boot, and a + // disable landing in that window lands on OUR entry. Restoring the + // pre-install state deletes the edit the user just made — and the next turn, + // finding no entry at all, spawns and re-enables. Round 4 arriving through + // the undo path. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // The user switches the entry off during the boot window, and the binding + // moves, so the attach is superseded and must undo. + projectNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "the undo ran").toHaveLength(1) + const restored = h.restores[0] as ExistingEntry | null + expect(restored, "deleted the entry the user had just disabled").not.toBeNull() + expect(restored?.enabled, "undid the user's disable").toBe(false) + }) +}) + +describe("INVARIANT #13 as a property — every seam, made to throw", () => { + // Stated once over the whole seam list rather than as a handful of cases, + // because the defect this catches is not a wrong answer but a MISSING + // question: nothing else here asks what a function does when a read fails. + // Two instances survived nineteen review rounds on this branch and two more + // on a sibling, and none of ordering, completeness, staleness or adjacency + // could see any of them — they all test what happens when reads succeed. + // + // Three things must hold for every seam: + // 1. no mutation is performed on the strength of a failed read; + // 2. the session settles with an outcome — never a rejected promise, which + // the caller starts fire-and-forget and would therefore never see; + // 3. the user is told at most once, and never twice. + // + // NOTE THE LIMIT, because it is the same limit that hid the original defect: + // these throw from the SEAM, so they prove the CALLERS handle a failed read. + // They cannot see a reader that swallows beneath the seam and hands up a + // confident `null` — restoring exactly that swallow leaves every test here + // green. That layer is covered in `engine-config-freshness.test.ts`, which + // throws from the config module itself. A property is only as deep as the + // layer it is written at, and this class lives at whichever layer answers. + const SEAMS = [ + "resolveBinding", + "existingEntry", + "projectEntry", + "projectConfigPath", + "versionOf", + "declared", + "persist", + "persistRestore", + ] as const + + for (const seam of SEAMS) { + test(`${seam} throwing never becomes an answer`, async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const boom = async () => { + throw new Error(`${seam} exploded`) + } + ;(syncInternals as Record)[seam] = boom + + // (2) settles rather than rejecting + const outcome = await ensure("s1") + expect(outcome, `${seam}: the session never settled`).toBeDefined() + expect(typeof outcome.kind).toBe("string") + + // (1) a failed read never authorises a write + if (seam !== "persist" && seam !== "persistRestore") { + expect(h.persisted, `${seam}: wrote config on the strength of a failed read`).toHaveLength(0) + } + + // (3) told at most once + expect(h.toasts.length, `${seam}: told the user ${h.toasts.length} times`).toBeLessThanOrEqual(1) + }) + } + + for (const seam of ["status", "tools", "spawned"] as const) { + test(`mcp.${seam} throwing never becomes an answer`, async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + ;(syncInternals.mcp as unknown as Record)[seam] = async () => { + throw new Error(`${seam} exploded`) + } + const outcome = await ensure("s1") + expect(outcome, `mcp.${seam}: the session never settled`).toBeDefined() + expect(h.toasts.length, `mcp.${seam}: told the user ${h.toasts.length} times`).toBeLessThanOrEqual(1) + }) + } +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts index 1550d5a24c..a35feafe3b 100644 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts @@ -31,7 +31,6 @@ test("ensure: a project `datamate: { enabled: false }` marker is not spawned ove // The entry has no `type`, so status() never lists it — until WE add it. status: async () => (live ? { datamate: { status: "connected" } } : {}), add: async (n, c) => { added.push({ n, c }); live = true }, - connect: async () => {}, remove: async () => {}, tools: async () => ({ datamate_dbt_build_model: {} }), } diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts index 897b34d161..17d6baadd0 100644 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts @@ -23,7 +23,6 @@ function base(opts: { existing: unknown; statuses: Record (q.length > 1 ? q.shift()! : q[0]!), add: async (n, c) => { h.added.push({ n, c }) }, - connect: async (n) => { h.connects.push(n) }, remove: async (n) => { h.removes.push(n) }, tools: async () => ({ datamate_dbt_build_model: {} }), } diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts index cdd6b725de..6c6087676d 100644 --- a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts @@ -74,9 +74,6 @@ function install( h.added.push({ name, cfg }) h.spawnedNow = cfg as ExistingEntry }, - connect: async (name) => { - h.connects.push(name) - }, remove: async (name) => { h.removes.push(name) h.spawnedNow = undefined diff --git a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts index eda8ebc0bc..8bb469c68f 100644 --- a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts @@ -50,7 +50,6 @@ function install(opts: { syncInternals.mcp = { status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), add: async (name, cfg) => { h.added.push({ name, cfg }) }, - connect: async (name) => { h.connects.push(name) }, remove: async (name) => { h.removes.push(name) }, tools: async () => h.tools, } @@ -132,6 +131,8 @@ describe("F — connect-failed after install, superseded: stale pin stays on dis // The re-link lands during the add, so the refusal revalidates and declines // to answer for the workspace the project has left. expect(outcome).toMatchObject({ kind: "superseded" }) + // Both halves: the pin WAS written, and it was given back. + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) expect(h.restores.length, "the failed spawn's pin was left on disk to wedge the next turn").toBeGreaterThan(0) }) test("turn 2 under binding 99: the failing 42 pin is retried once and refused — 99 never spawns", async () => { @@ -193,10 +194,19 @@ describe("C — retry-connect calls MCP.connect on a global-only entry (persists statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], }) await ensure("s1") - // ADAPTED ON LIFT: the finding is fixed. Repairing a down IDE-shaped entry - // used `MCP.connect`, which persists `enabled: true` into the file that owns - // the entry — a global write from a local decision. It re-adds now. - expect(h.connects, "repaired a global entry by writing to it").toHaveLength(0) + // ADAPTED ON LIFT, then STRENGTHENED. The finding is fixed: repairing a down + // IDE-shaped entry used `MCP.connect`, which persists `enabled: true` into + // the file that owns the entry — a global write from a local decision. + // + // Asserting only "connect was not called" is now vacuous, since the seam no + // longer carries it. What earns its place is that the repair happened, with + // the right primitive and the entry we judged, and wrote nothing. + // And the scenario no longer reaches the repair at all: an IDE-shaped entry + // is UNPINNED, so attribution replaces it before connectivity is ever + // consulted. What lands is our own pinned entry, written to the project + // config — not a global write to theirs, which was the defect. + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) }) }) diff --git a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts index b92548ece2..ef12f3c74e 100644 --- a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts +++ b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts @@ -50,9 +50,6 @@ function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null): add: async (name, cfg) => { h.added.push({ name, cfg }) }, - connect: async (name) => { - h.connects.push(name) - }, remove: async (name) => { h.removes.push(name) }, @@ -94,68 +91,17 @@ describe("L3 (a') — a disable lands INSIDE the retry's connect window", () => expect(h.removes).toEqual(["datamate"]) }) - test("RESIDUAL: MCP.connect's persistMcpEnabled(true) RMW rewrites the disable before the re-inspection can see it", async () => { - let enabled = true - const h = install( - [ - { datamate: { status: "failed", error: "exit 1" } }, - { datamate: { status: "connected" } }, - { datamate: { status: "connected" } }, - { datamate: { status: "connected" } }, - ], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), - ) - syncInternals.mcp!.connect = async (name) => { - h.connects.push(name) - enabled = false // the user's disable lands during the handshake (mcp/index.ts:914 createAndStore) - enabled = true // ...and connect's persistMcpEnabled(name, true) RMW (mcp/index.ts:917 → 986-988) writes over it - } - expect((await ensure("s1")).kind).toBe("reused") - expect((await ensure("s1")).kind).toBe("reused") - // One more read than before: the pre-revive guard now confirms intent as - // well as the binding before starting anything. - expect(h.reads).toEqual([true, true, true, true]) - expect(h.removes).toEqual([]) - }) + // REMOVED ON LIFT — staged by hooking `MCP.connect`, which the attach flow no + // longer has. Its residual (connect's read-modify-write reverting a disable) + // cannot occur, and a test whose hook never fires asserts nothing. }) -describe("L3 (c) — MCP.disconnect lands between the config read and the status read inside inspectEntry", () => { - test("RESIDUAL: planForEntry sees enabled+disabled → retry-connect → MCP.connect is invoked", async () => { - let enabled = true - const h = install([{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], () => ({ - type: "local", - command: ["datamate", "start-stdio", "--datamate", "42"], - enabled, - })) - const realStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { - // disconnect (prompt.ts:3004 / routes/mcp.ts:228): status → disabled, disk → enabled:false - enabled = false - return realStatus() - } - // ADAPTED ON LIFT — the residual this documented is closed. It existed - // because `MCP.connect` performed a read-modify-write of `enabled: true`, - // reverting a disable that had just landed. The retry re-adds now and writes - // no config, so nothing reverts the user's edit. - const previousAddC = syncInternals.mcp!.add - syncInternals.mcp!.add = async (name, cfg) => previousAddC(name, cfg) - const outcome = await ensure("s1") - expect(h.connects, "reverted a disable by repairing through the config-writing primitive").toHaveLength(0) - // Better than the `reused` this documented, and better than a bare - // `superseded`: the re-inspection sees the disable and names it. - expect(outcome.kind).toBe("entry-disabled") - }) - - test("control: the same disconnect landing BEFORE the config read is honoured", async () => { - const h = install([{ datamate: { status: "disabled" } }], () => ({ - type: "local", - command: ["datamate", "start-stdio", "--datamate", "42"], - enabled: false, - })) - expect((await ensure("s1")).kind).toBe("entry-disabled") - expect(h.connects).toEqual([]) - }) -}) +// REMOVED ON LIFT — this describe staged its scenario by hooking `MCP.connect`, +// which the attach flow no longer has: the seam member is gone and a call to it +// would not compile. Its residual (connect's read-modify-write reverting a +// disable) cannot occur, and a test whose hook never fires asserts nothing. +// The surviving property — a disable landing mid-decision is honoured — is +// covered by the guard and write-refusal tests in engine-sync.test.ts. describe("L3 (f) — the plan derived from an Inspection is held across the probes, then persist writes enabled:true", () => { test("replace-unattributable: a disable landing during the PATH probe is persisted over, and the memo never re-checks", async () => { diff --git a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts index 9162657cf1..111814c351 100644 --- a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts @@ -93,9 +93,6 @@ function install(opts: { add: async (name, cfg) => { h.added.push({ name, cfg }) }, - connect: async (name) => { - h.connects.push(name) - }, remove: async (name) => { h.removes.push(name) }, From 5ee7309b8fe175e16e4d757b272e4875046b498c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:21:46 +0800 Subject: [PATCH 46/67] test(workspace): give the observation tests assertions, and name what cannot be checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One lifted case was a `console.log` — an observation, not a test: it could not fail, so it protected nothing. It asserts the property it was written for now, which is that a re-link during memo validation no longer drops the turn's wait. Writing it also corrected me. My first assertion said the outcome must not be `reused`, and `reused` is exactly right: the memo for the old workspace IS rejected, the attach re-decides for the new one, and the new one's entry is live and attributable — so reuse is what re-deciding concludes. Asserting the verdict rather than the property would have pinned a misreading. And the write-side cache invalidation now says why it is unobservable from this module's tests — every read here invalidates first, so removing it changes nothing the suite can see. It exists for the other `Config` consumers in the process, which do not invalidate before reading. Better to record that than leave a claim the suite silently fails to check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 6 ++++++ .../altimate/workspace/gate-l2-repro2.test.ts | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index f0d6356ccb..317632071b 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -48,6 +48,12 @@ export async function persist(name: string, cfg: LocalMcpConfig, configPath?: st // how a managed entry becomes unrecognisable to `isManagedEntry` later in the // same server process, leaving a stale engine attached in an unbound project. // The local-config write path in `config.ts` invalidates for the same reason. + // NOT observable from this module's own tests, and worth saying so rather than + // leaving a claim the suite silently fails to check: every read here + // invalidates first, so a missing invalidation on the WRITE side changes + // nothing we can see. It is here for the other `Config` consumers in the + // process, which do not invalidate before reading and would otherwise serve a + // cached config that predates our write. await Config.invalidate().catch((err) => { log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) }) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts index 17d6baadd0..c0ec4d9e02 100644 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts @@ -129,6 +129,21 @@ test("(e) a re-link during memo validation: the next attach is filed under the O await whenAttached("s1", 2000) const waited = Date.now() - started const out2 = await t2 - const out3 = await ensure("s1") - console.log("(e) turn2:", JSON.stringify(out2), "waited ms:", waited, "turn3:", JSON.stringify(out3), "settled:", JSON.stringify(settledOutcome("s1"))) + await ensure("s1") + // GIVEN A REAL ASSERTION ON LIFT — it was a console.log, which is an + // observation, not a test: it could not fail and so could not protect + // anything. + // + // The session key is recomputed AFTER the awaited validation now, so a + // re-link landing inside it files the attach under the workspace it actually + // ended up on. The turn therefore waits for the attach it needs rather than + // returning instantly against a key that is already stale. + // `reused` is the RIGHT answer here and my first assertion said otherwise: + // the memo for 42 is correctly rejected, the attach re-decides for 99, and 99's + // entry is live and attributable — so reuse is what re-deciding concludes. The + // property is that the turn waited for the attach it actually needs rather + // than returning instantly against a key that was already stale. + expect(waited, "returned instantly on a key that was already stale").toBeGreaterThan(0) + expect(settledOutcome("s1"), "the session never settled").toBeDefined() + expect(out2.kind).toBe("reused") }) From 6cb70bb43f512240d24e9f75e2fbbd32004b5da9 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:25:18 +0800 Subject: [PATCH 47/67] test(workspace): assert the wait was honoured, not that it took measurable time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion I had just added was flaky by construction: a fast path measures 0ms at `Date.now()` resolution, and the suite duly failed on it one run later. Elapsed time was never the property anyway — "the turn waits for the attach it needs" means the attach has SETTLED by the time the wait returns, which is deterministic and is what dropping the wait would break. Stable across three consecutive runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../test/altimate/workspace/gate-l2-repro2.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts index c0ec4d9e02..9f08651bf9 100644 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts @@ -128,6 +128,7 @@ test("(e) a re-link during memo validation: the next attach is filed under the O const started = Date.now() await whenAttached("s1", 2000) const waited = Date.now() - started + const settledAtResolve = settledOutcome("s1") const out2 = await t2 await ensure("s1") // GIVEN A REAL ASSERTION ON LIFT — it was a console.log, which is an @@ -143,7 +144,12 @@ test("(e) a re-link during memo validation: the next attach is filed under the O // entry is live and attributable — so reuse is what re-deciding concludes. The // property is that the turn waited for the attach it actually needs rather // than returning instantly against a key that was already stale. - expect(waited, "returned instantly on a key that was already stale").toBeGreaterThan(0) - expect(settledOutcome("s1"), "the session never settled").toBeDefined() + // Not elapsed time — that assertion was flaky by construction, since a fast + // path measures 0ms at `Date.now()` resolution and the suite duly failed on + // it. The property is that the wait was actually honoured: the attach has + // SETTLED by the time `whenAttached` returns, which is what "the turn waits + // for the attach it needs" means and what dropping the wait would break. + void waited + expect(settledAtResolve, "resolved the turn before the attach it needs had settled").toBeDefined() expect(out2.kind).toBe("reused") }) From 5132fecac0ff820fdc479d24354c40c6b18eb8ea Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:36:34 +0800 Subject: [PATCH 48/67] fix(workspace): an unbound project stays silent, whatever fails inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression from making the config reader propagate. The `!binding` branch reads the entry only to produce a log line, and it silently inherited the new behaviour: a failed read escaped to the catch-all and announced "Workspace engine attach failed" in a project with no workspace linked, where this module is documented inert and most projects live. `connect-failed` is repairable, so it announced again on every turn for as long as the config stayed unreadable. Propagating a failure instead of swallowing it is the right default, and it is worth naming what it costs: every caller that relied on the swallow becomes a decision that now has to be made explicitly. For the paths that DECIDE on the read, propagating is the whole point. For a read whose only consumer is a log line, nothing is riding on the answer and the outcome must not change — so it is caught where it is read, rather than by widening the rule that made it visible. Also worth recording from the same review, since it is a deliberate behaviour change rather than a side effect: with `remove` now clearing the runtime config, the MCP route that used to re-spawn a removed server from retained state returns NotFound instead. That re-spawn was the defect — "removed" has to mean the runtime forgets it — and no in-repo consumer depends on the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 36 ++++++++++++++----- .../altimate/workspace/engine-sync.test.ts | 23 ++++++++++++ .../altimate/workspace/gate-r3-ah.test.ts | 33 +++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 3909f7c0aa..efd2807521 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -319,16 +319,34 @@ async function run(): Promise { // not act on something you cannot attribute, so it applies to itself here: // report it and leave it alone. Attributing this properly needs an explicit // ownership marker written at persist time, which is a separate change. - const present = (await client.status())[DATAMATE_KEY] - if (present) { - const stale = await existingEntry(DATAMATE_KEY) - const pin = pinnedWorkspace(stale) - if (pin) { - log.info("unbound project has an engine entry pinned to a workspace; leaving it alone", { - pinnedTo: pin, - entry: describeEntry(stale), - }) + // This read is DIAGNOSTIC — it produces a log line and nothing else — so a + // failure to perform it must not change the outcome. Making the reader + // propagate was right for the paths that DECIDE on it, and this caller + // silently inherited that: a failed read here escaped to the catch-all and + // announced "Workspace engine attach failed" in a project with no workspace + // linked, where this module is documented inert — and because that outcome + // is repairable, it re-announced on every turn for as long as the config + // stayed unreadable. + // + // Propagating a failure is the right default, but it turns every caller that + // relied on the swallow into a decision that now has to be made explicitly. + // Here the decision is easy, because nothing is riding on the answer. + try { + const present = (await client.status())[DATAMATE_KEY] + if (present) { + const stale = await existingEntry(DATAMATE_KEY) + const pin = pinnedWorkspace(stale) + if (pin) { + log.info("unbound project has an engine entry pinned to a workspace; leaving it alone", { + pinnedTo: pin, + entry: describeEntry(stale), + }) + } } + } catch (err) { + log.warn("could not inspect the stale entry in an unbound project; nothing depends on it", { + err: String(err), + }) } return { kind: "unbound" } } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index b59eb88874..c6e9515312 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2316,3 +2316,26 @@ describe("INVARIANT #13 as a property — every seam, made to throw", () => { }) } }) + +describe("INVARIANT — an unbound project stays silent, whatever fails inside it", () => { + test("an unreadable config in a project with no binding does not announce, on any turn", async () => { + // The module is documented inert when nothing is linked, and most projects + // are not linked. Making the config reader propagate was right for the paths + // that DECIDE on it — and this diagnostic read, which produces a log line + // and nothing else, silently inherited it: the failure escaped to the + // catch-all and announced "attach failed" in a project that never wanted an + // attach. `connect-failed` is repairable, so it announced again every turn. + const h = install({ + binding: null, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.existingEntry = async () => { + throw new Error("EIO: config unreadable") + } + for (const turn of [1, 2, 3]) { + const outcome = await ensure(`s${turn}`) + expect(outcome.kind, `turn ${turn}: an unbound project reported an attach failure`).toBe("unbound") + } + expect(h.toasts, "an unbound project announced something").toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts b/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts new file mode 100644 index 0000000000..6f4d7639d7 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts @@ -0,0 +1,33 @@ +// Round-3 AH/T probes — NOT for commit. +import { afterEach, beforeEach, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, planForEntry, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + +test("AH: UNBOUND project, config read throws in the diagnostic branch → must stay `unbound` and silent", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null // unbound + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + const out = await ensure("s1") + console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) + expect(out.kind).toBe("unbound") + expect(toasts).toHaveLength(0) +}) + +test("T phantom: entry null + synthesised status (key known to MCP but not to config)", () => { + const noRuntime = planForEntry({ entry: null, observed: { status: "failed", error: "exit 1" }, runtime: undefined }, "42", false) + const withRuntime = planForEntry({ entry: null, observed: { status: "connected" }, runtime: { type: "local", command: ["datamate", "start-stdio"] } }, "42", false) + console.log("T phantom noRuntime:", JSON.stringify(noRuntime), "| withRuntime:", JSON.stringify(withRuntime)) +}) + +test("AH: the unbound escalation repeats every turn (connect-failed is REPAIRABLE)", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + await ensure("s1"); await ensure("s1"); await ensure("s1") + console.log("AH repeat: toasts over 3 turns =", toasts.length, toasts.map((t) => t.title)) +}) From 8c78d98eb288d3396d6390be0c25448a9de38ac2 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 10:56:02 +0800 Subject: [PATCH 49/67] fix(workspace): say it once, guard the adjacent half, and let the undo refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit round of the adversarial gate. Nine items; the ones worth reading: An unchanged verdict is now announced once per session rather than once per turn. Repairable refusals re-enter the machine every turn by design — that is how a repair gets noticed — but re-DECIDING is not a reason to re-TELL, and a missing engine produced an identical toast on every turn. It matters more once that toast becomes a dialog: one dialog per turn would be unusable. A changed verdict speaks, and a successful attach clears the record so the next problem is heard. Two of my own tests claimed this and did not assert it. Their titles said "refuses once, rather than toasting every turn"; they asserted the first turn only. They assert across three turns now. The guard's read order goes back to intent first, binding last, reversing what the previous commit did. Reading the binding first put it one whole config read away from every mutation it guards, so a re-link landing inside that read installed for the workspace the project had just left — round 19's defect at round 19's own site, reintroduced by the guard's ordering. Intent does not need the adjacent position, because the write re-checks intent on the same text it modifies; the binding has no such second line of defence, so it takes it. On the revive path there is no write-side check and one half is necessarily a read away — the binding still goes last, because a stale binding starting another workspace's engine under the lock is the worse harm. That judgment is in the code. The undo stops writing blind. Its own re-read failed OPEN — a throw there restored what we replaced, which can overwrite a disable it could not read — while the identical read in the guard fails closed. It leaves the file alone now and raises the one undo-failure toast. Its WRITE gains the same same-text check the forward write has, including the case where restoring means DELETING: a node the user has since disabled is kept, not removed. A revive is an install and owns its undo: a throw in the re-inspection used to reach the catch-all with the client we had just started still registered and serving. `committed` and the `finally` had become two backstops where each alone was undetectable. One remains, and the properties that are actually real — undo before announce at the in-region refusals, an undo that throws is not a silent supersede, two distinct failures are two signals — are pinned instead of implied. Coverage the mutants demanded: the adjacency trace now wraps every seam including the quiet ones; a disable staged inside the boot window with the binding UNCHANGED, so the guard's intent half is load-bearing; a first-read- only inspection failure; the restore's path asserted through the seam that now carries it; and two production paths that were only ever verified at the seam — the restore's real failure return, against an unwritable file, and the spawn record's clearing on disconnect and on child exit. One stale comment fixed: the guard no longer claims the write closes the window "at the one point nothing can intervene", which its own file already retracted one directory over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 30 ++- .../src/altimate/workspace/engine-seams.ts | 6 +- .../src/altimate/workspace/engine-sync.ts | 116 +++++++-- .../workspace/engine-config-freshness.test.ts | 24 ++ .../workspace/engine-sync-gate-l1.test.ts | 2 +- .../altimate/workspace/engine-sync.test.ts | 226 +++++++++++++++++- .../altimate/workspace/gate-l3-r2.test.ts | 14 +- .../altimate/workspace/gate-r3-ah.test.ts | 8 +- packages/opencode/test/mcp/lifecycle.test.ts | 41 ++++ 9 files changed, 437 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 317632071b..2d4a214dd9 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -111,14 +111,38 @@ export async function persistRestore( previous: ExistingEntry | null, configPath?: string, ): Promise<"restored" | "failed"> { - if (syncInternals.persistRestore) return (await syncInternals.persistRestore(name, previous)) ?? "restored" + // The seam takes the path too, so a test can assert the undo uses the path the + // write used. Without it, dropping that argument was invisible: the stub + // discarded what it was never given. + if (syncInternals.persistRestore) return (await syncInternals.persistRestore(name, previous, configPath)) ?? "restored" try { // The SAME path the write used, not a fresh resolution: re-resolving can // pick a different file than the one we wrote to, in which case the undo // edits a config we never touched and leaves the one we did. const target = configPath ?? (await resolveConfigPath(projectRoot())) - if (previous) await addMcpToConfig(name, previous as never, target) - else await removeMcpFromConfig(name, target) + // The undo's write needs the same same-text check as the write it undoes. + // Without it the restore has its own version of the window `persist` closes: + // a disable landing before this write is replaced wholesale — and in the + // `previous === null` case it is DELETED, which is worse than overwritten. + if (previous) { + if ((await addMcpToConfig(name, previous as never, target, { refuseIfDisabled: true })) === null) { + log.info("not restoring over an entry that is disabled on disk", { name }) + return "restored" + } + } else { + const onDisk = (await readMcpEntryFromDisk(name, target)) as ExistingEntry | undefined + if (onDisk?.enabled === false) { + // We were going to remove our entry because there was none before. The + // user has since switched this one off, which is an instruction about + // this node — honour it rather than deleting the node they just edited. + log.info("not removing an entry the user has disabled", { name }) + return "restored" + } + await removeMcpFromConfig(name, target) + } + // Unobservable from this module's own tests for the same reason `persist`'s + // is — every read here invalidates first. It is here for the other `Config` + // consumers in the process. await Config.invalidate().catch(() => undefined) return "restored" } catch (err) { diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 477a0d790e..6b44e9eca3 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -25,7 +25,11 @@ export const syncInternals: { } persist?: (name: string, cfg: LocalMcpConfig) => Promise projectConfigPath?: () => Promise - persistRestore?: (name: string, previous: ExistingEntry | null) => Promise + persistRestore?: ( + name: string, + previous: ExistingEntry | null, + configPath?: string, + ) => Promise projectEntry?: () => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index efd2807521..0dfe6530ff 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -243,6 +243,32 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie return { act: "check-version" } } +/** The last verdict announced to each session. + * + * Repairable refusals are re-decided every turn — deliberately, because that is + * how a repair gets noticed — but re-DECIDING is not a reason to re-TELL. A + * missing engine, an unreadable config or a below-floor binary that has not + * changed produced an identical toast on every single turn, which is nagging + * rather than informing. It matters more once the toast becomes a dialog: one + * dialog per turn would be unusable. + * + * Keyed by session and by the verdict itself, so a CHANGED verdict speaks, and a + * successful attach clears the record so the next problem is heard. */ +const lastAnnounced = new Map() + +function verdictSignature(outcome: Outcome): string { + const detail = + "error" in outcome ? outcome.error : "found" in outcome ? outcome.found : "declared" in outcome ? "" : "" + return `${outcome.kind}:${detail}` +} + +/** Forget what a session was last told, so the next verdict is announced even if + * it repeats an older one. Called when an attach succeeds: the problem the user + * was told about is gone, and if it comes back they should hear about it. */ +function clearAnnouncement(sessionID: string): void { + lastAnnounced.delete(sessionID) +} + /** Tell the user about a refusal — exactly once, from one place. * * This is a whole function for what is currently one call because it is a @@ -290,6 +316,15 @@ type RefusalContext = { async function announceRefusal(outcome: Outcome, toast: Toast, context?: RefusalContext): Promise { try { + const sessionID = context?.sessionID + if (sessionID) { + const signature = verdictSignature(outcome) + if (lastAnnounced.get(sessionID) === signature) { + log.info("verdict unchanged since the last turn; not repeating it", { sessionID, kind: outcome.kind }) + return + } + lastAnnounced.set(sessionID, signature) + } if (installWouldHelp(outcome)) { log.info("refusal is remediable by installing the engine", { ...context, kind: outcome.kind }) } @@ -303,7 +338,7 @@ async function announceRefusal(outcome: Outcome, toast: Toast, context?: Refusal } } -async function run(): Promise { +async function run(sessionID: string): Promise { if (!isEnabled()) return { kind: "disabled" } const client = mcp() @@ -422,12 +457,29 @@ async function run(): Promise { * * Both reads live in one function so nothing can be inserted between them, and * this is the LAST await before any mutation. The invariant is not "no - * mutation on a stale binding" but "no mutation on a stale world". */ + * mutation on a stale binding" but "no mutation on a stale world". + * + * It does NOT make the window vanish. The write re-checks intent on the same + * text it modifies, which is as close as that can be got, but one read and one + * write to one file is not atomic — see the note on `persist`. This guard + * narrows the window; it does not close it. */ const worldUnchanged = async (): Promise<"ok" | "moved" | "disabled" | "unreadable"> => { - // Binding FIRST, intent LAST, so the only thing standing between the intent - // check and the write is the write's own read — which does the check again, - // at the one point nothing can intervene. - if (!(await stillCurrent())) return "moved" + // Intent FIRST, binding LAST — reversed again, and this is the considered + // order rather than the obvious one. + // + // Reading the binding first put it one whole config read away from every + // mutation it guards, so a re-link landing inside that read installed for the + // workspace the project had just left and was only undone after the engine + // had booted with the per-project lock held. That is the exact defect this + // guard was written for, reintroduced by the guard's own ordering. + // + // Intent does not need to be last, because the write re-checks intent on the + // same text it modifies — so the intent window is covered whichever read + // comes first. The binding has no such second line of defence, so it takes + // the adjacent position. On the re-add path there is no write-side check at + // all and one half is necessarily a read away; the binding still goes last, + // because a stale binding starting another workspace's engine under the lock + // is the worse of the two harms. let entryNow: ExistingEntry | null try { entryNow = await existingEntry(DATAMATE_KEY) @@ -450,6 +502,7 @@ async function run(): Promise { log.info("intent changed while deciding; not writing over a disable", { workspaceId }) return "disabled" } + if (!(await stillCurrent())) return "moved" return "ok" } @@ -556,10 +609,16 @@ async function run(): Promise { try { now = await projectEntry() } catch (err) { - log.warn("could not read the project entry before undoing; restoring what we replaced", { + // Fails CLOSED, like the guard's read and for the same reason. Restoring + // "what we replaced" on a read we could not perform can overwrite a + // disable that landed while we held the entry — writing blind is how the + // undo becomes the thing that needs undoing. Leave the file alone and let + // the caller tell the user what is still there. + log.warn("could not read the project entry before undoing; leaving the file alone", { workspaceId, err: String(err), }) + return "failed" } if (now?.enabled === false) { log.info("the entry was disabled while we held it; keeping the disable rather than undoing it", { @@ -608,7 +667,7 @@ async function run(): Promise { }) return { kind: "superseded" } } - await announceRefusal(outcome, toast, { workspaceId, workspaceName: binding.datamateName }) + await announceRefusal(outcome, toast, { workspaceId, workspaceName: binding.datamateName, sessionID }) return outcome } @@ -666,12 +725,31 @@ async function run(): Promise { if (beforeRevive === "disabled") return await refuseDisabled() if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeRevive !== "ok") return { kind: "superseded" } - await client.add(DATAMATE_KEY, revive).catch((err) => { - log.warn("could not restart the engine entry", { err: String(err), workspaceId }) - }) + let revived = false + await client + .add(DATAMATE_KEY, revive) + .then(() => { + revived = true + }) + .catch((err) => { + log.warn("could not restart the engine entry", { err: String(err), workspaceId }) + }) // Re-inspected whole rather than re-reading status alone: the world may // have moved in both halves while we were starting a process. - inspection = await inspectEntry() + // + // A revive is an install, so it owns its undo like one. A throw in the + // re-inspection used to propagate straight to the catch-all with the client + // WE had just started still registered and serving — one external failure, + // not two, and the same advice-versus-registration split as everywhere else. + try { + inspection = await inspectEntry() + } catch (err) { + if (revived) { + log.info("undoing the revive we started, since we cannot decide about it", { workspaceId }) + await client.remove(DATAMATE_KEY).catch(() => undefined) + } + throw err + } plan = planForEntry(inspection, workspaceId, true) } const entry = inspection.entry @@ -817,6 +895,7 @@ async function run(): Promise { }) return { kind: "superseded" } } + clearAnnouncement(sessionID) log.info("reusing existing engine entry", { workspaceId, available, @@ -1013,7 +1092,7 @@ async function run(): Promise { `That pin is still on disk and will start on the next restart; edit or remove it to be sure.`, variant: "error", }, - { workspaceId, workspaceName: binding.datamateName }, + { workspaceId, workspaceName: binding.datamateName, sessionID }, ) } } @@ -1084,6 +1163,10 @@ async function run(): Promise { // the toast are two more awaits, and the outcome asserts which workspace is // served — round 13's rule, which the announces quietly put back at risk. committed = true + // The problem the user was last told about is gone. If it returns, they + // should hear about it rather than have it deduplicated against a verdict + // from before the repair. + clearAnnouncement(sessionID) const outcome: Outcome = { kind: "attached", available, @@ -1127,7 +1210,9 @@ async function run(): Promise { // is the same situation as any other refusal for a workspace the project has // left: answering names the wrong workspace and toasting is worse. The // catch-all announces every throw it sees, so this one must not reach it. - await undoNow() + // The `finally` performs the undo — one backstop, not two. It runs before + // this function's value reaches the caller, and before the catch-all + // announces anything, so the ordering that matters still holds. if (!(await stillCurrent())) { log.info("attach threw after the binding moved; not answering for the old workspace", { workspaceId, @@ -1440,7 +1525,7 @@ async function failSafely(sessionID: string, task: () => Promise): Prom } function attachOnce(sessionID: string): Promise { - return serializeAttach(() => run()) + return serializeAttach(() => run(sessionID)) .then((outcome) => { // One line per session, whatever happened — silence is the defect this // module exists to remove, so it must not be silent about itself. @@ -1511,4 +1596,5 @@ export async function whenAttached(sessionID: string, timeoutMs: number = ATTACH export function resetForTests(): void { sessions.clear() attachChains.clear() + lastAnnounced.clear() } diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts index e4cbc52b85..5f0ce82424 100644 --- a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -109,3 +109,27 @@ describe("INVARIANT #13 at the reader — a failed read propagates, never become expect(await existingEntry("datamate")).toBeNull() }) }) + +describe("INVARIANT — the restore reports failure from the real write, not just the seam", () => { + test("an unwritable config file yields 'failed', which is what raises the toast", async () => { + // The suite's "undo that could not be confirmed" test stubs the seam to + // RETURN "failed" — so the production path that decides to return it was + // never exercised, and making it return "restored" instead left everything + // green. Same layer-below shape that hid the reader's swallow. + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const { mkdtempSync, writeFileSync, chmodSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const path = await import("node:path") + + const dir = mkdtempSync(path.join(tmpdir(), "restore-")) + const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate"] } } }, null, 2)) + chmodSync(file, 0o444) + try { + const result = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] }, file) + expect(result, "an unwritable file was reported as a successful restore").toBe("failed") + } finally { + chmodSync(file, 0o644) + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts index a50c8cd016..bc5682a27e 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts @@ -148,7 +148,7 @@ describe("T1 — the last awaited seam before every mutation is the binding read // TEARDOWN needs only the binding, since intent neither authorises nor // forbids stopping a client. const isWrite = trace[i] === "persist" || trace[i] === "add" - if (isWrite && before === "existingEntry" && beforeThat === "resolveBinding") continue + if (isWrite && before === "resolveBinding" && beforeThat === "existingEntry") continue if (!isWrite && !removesAreBindingDependent) continue if (!isWrite && before === "resolveBinding") continue out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index c6e9515312..06e1c58e81 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -47,6 +47,7 @@ type Harness = { toasts: Array<{ title: string; message: string; variant: string }> toolsChanged: number restores: Array + restorePaths: Array statusQueue: Array> tools: Record spawnedNow?: ExistingEntry @@ -69,6 +70,7 @@ function install(opts: { toasts: [], toolsChanged: 0, restores: [], + restorePaths: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {}, // A configured entry that is already CONNECTED was bootstrapped from that @@ -102,8 +104,9 @@ function install(opts: { syncInternals.toolsChanged = async () => { h.toolsChanged += 1 } - syncInternals.persistRestore = async (_name, previous) => { + syncInternals.persistRestore = async (_name, previous, configPath?: string) => { h.restores.push(previous ?? null) + h.restorePaths.push(configPath) } // The project file has no entry of its own unless a test says otherwise. This // used to be supplied by accident: the real reader swallowed its own errors @@ -1946,6 +1949,9 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo syncInternals.projectEntry = wrapRead("projectEntry", syncInternals.projectEntry!) syncInternals.declared = wrapRead("declared", syncInternals.declared!) syncInternals.versionOf = wrapRead("versionOf", syncInternals.versionOf!) + syncInternals.projectConfigPath = wrapRead("projectConfigPath", syncInternals.projectConfigPath!) + syncInternals.notify = wrapRead("notify", syncInternals.notify!) + syncInternals.toolsChanged = wrapRead("toolsChanged", syncInternals.toolsChanged!) syncInternals.persist = wrapMutation("persist", syncInternals.persist!) syncInternals.persistRestore = wrapMutation("persistRestore", syncInternals.persistRestore!) const m = syncInternals.mcp! @@ -1996,6 +2002,11 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo // a disabled or below-floor engine, are right whatever the project is bound to // now, so requiring a binding read before those would assert the opposite of // what they are for. + // + // LIMIT, stated rather than left implicit: the exemption is per SCENARIO, not + // per teardown. It is precise today only because no single run() produces both + // a binding-dependent and a binding-independent teardown — if one ever does, + // this needs the reason threaded through the trace instead. const bindingIndependent = new Set(["replacing an engine below the floor"]) for (const [name, opts] of scenarios) { @@ -2016,7 +2027,10 @@ describe("INVARIANT — the last thing awaited before a mutation is the whole wo // A WRITE needs the whole world: `enabled: false` forbids creating // anything, so intent is part of the question. if (step === "persist" || step === "add") { - if (before === "existingEntry" && beforeThat === "resolveBinding") return + // The BINDING read is the one that must be adjacent: intent has a + // second line of defence in the write's own same-text check, and the + // binding has none. + if (before === "resolveBinding" && beforeThat === "existingEntry") return } else if (!bindingDependentRemoves || before === "resolveBinding") { // A TEARDOWN only needs the binding. Intent neither authorises nor // forbids stopping a client: a disabled entry is torn down regardless, @@ -2168,7 +2182,7 @@ describe("INVARIANT — announcing never changes what happened", () => { }) describe("INVARIANT — a rejected engine is detached even when the rejection is a failure to know", () => { - test("a probe that THROWS detaches and refuses once, rather than toasting every turn", async () => { + test("a probe that THROWS detaches and refuses, and says so once across turns", async () => { // Letting the probe's throw propagate reached the catch-all BEFORE any // teardown, so a persistent failure produced a toast on every turn while the // rejected client stayed registered and serving — the outcome is advice, the @@ -2186,9 +2200,15 @@ describe("INVARIANT — a rejected engine is detached even when the rejection is expect(h.removes, "left a rejected engine registered and serving").toContain("datamate") expect(h.toasts).toHaveLength(1) - // And the memo holds the refusal rather than re-refusing every turn. + // Repairable refusals are re-DECIDED every turn — that is how a repair gets + // noticed — but re-deciding is not a reason to re-TELL. The title of this + // test used to claim that and assert only the first turn; it asserts the + // claim now. const second = await ensure("s1") expect(second.kind).toBe("engine-too-old") + const third = await ensure("s1") + expect(third.kind).toBe("engine-too-old") + expect(h.toasts.length, "repeated an unchanged verdict on every turn").toBe(1) }) test("a re-link during the version probes still detaches a below-floor engine", async () => { @@ -2339,3 +2359,201 @@ describe("INVARIANT — an unbound project stays silent, whatever fails inside i expect(h.toasts, "an unbound project announced something").toHaveLength(0) }) }) + +describe("INVARIANT — an unchanged verdict is announced once, a changed one speaks", () => { + // Repairable refusals re-enter the machine every turn by design: re-probing is + // how a repair gets noticed. Re-deciding is not a reason to re-tell, and the + // difference matters more once the toast becomes a dialog — one dialog per + // turn would be unusable. + test("three turns of the same verdict produce one signal", async () => { + const h = install({ which: null }) + for (const _ of [1, 2, 3]) expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.length, "nagged on every turn about a verdict that had not changed").toBe(1) + }) + + test("a verdict that CHANGES is announced again", async () => { + const h = install({ which: null }) + expect((await ensure("s1")).kind).toBe("engine-missing") + // The user installs something, but it is too old — a different problem, and + // one they need to hear about. + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.5.9" + expect((await ensure("s1")).kind).toBe("engine-too-old") + expect(h.toasts.length, "a changed verdict was swallowed as a repeat").toBe(2) + }) + + test("after a repair succeeds, the next problem is heard again", async () => { + const h = install({ + which: null, + statuses: [ + {}, + {}, + { datamate: { status: "connected" } }, + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + tools: { datamate_dbt_build_model: 1 }, + }) + expect((await ensure("s1")).kind).toBe("engine-missing") + // Repair. + syncInternals.which = () => "/usr/local/bin/datamate" + expect((await ensure("s1")).kind).toBe("attached") + // The engine then dies AND the binary goes away — the same problem as turn + // one, and news again, because it was fixed in between. + syncInternals.which = () => null + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.filter((t) => t.title.includes("unavailable")).length, "silenced a problem that had returned").toBe( + 2, + ) + }) +}) + +describe("INVARIANT — the coverage the mutants demanded", () => { + test("a disable inside the boot window is caught even when the binding never moves", async () => { + // The only test that staged a disable during the boot window ALSO flipped + // the binding, so the post-install guard's intent half was never the thing + // doing the work — the binding half would have caught it either way. + let projectNow: ExistingEntry | null = null + let entryNow: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => entryNow + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // The user switches it off while the engine boots. The binding is + // untouched, so only the intent half of the guard can see this. + entryNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + projectNow = entryNow + } + const outcome = await ensure("s1") + expect(outcome.kind, "the guard's intent half was not load-bearing").toBe("entry-disabled") + expect(h.removes, "left a disabled engine registered").toContain("datamate") + }) + + test("an in-region refusal tears down BEFORE it announces", async () => { + // The `finally` would undo either way, so the ORDER was unpinned — and the + // order is the point: the announcement is a substitution point, and a body + // that waits on a person would hold a failed engine's registration and its + // pin for as long as the dialog is open. + const order: string[] = [] + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + const prevRemove = syncInternals.mcp!.remove + syncInternals.mcp!.remove = async (name: string) => { + order.push("teardown") + return prevRemove(name) + } + syncInternals.notify = async (toast) => { + order.push("announce") + h.toasts.push(toast) + } + await ensure("s1") + expect(order.indexOf("teardown"), "announced before it stopped serving").toBeLessThan(order.indexOf("announce")) + }) + + test("the undo restores through the path the write used, not one it resolves again", async () => { + // Re-resolving can pick a different file than the one we wrote to, in which + // case the undo edits a config we never touched and leaves the one we did. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restorePaths, "the undo resolved its own path instead of using the write's").toEqual([ + "/tmp/test/.altimate-code/altimate-code.json", + ]) + }) + + test("a FIRST-read-only failure at the inspection does not plan as 'nothing here'", async () => { + // The seam property throws on every read, so the guard stops the write and + // the property passes without the inspection's handling ever mattering. With + // only the first read failing, planning a failed read as "no entry" writes. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + if (reads === 1) throw new Error("EIO: first read only") + return good(name) + } + const outcome = await ensure("s1") + expect(h.persisted, "planned a failed inspection read as 'nothing here' and wrote").toHaveLength(0) + expect(h.added, "planned a failed inspection read as 'nothing here' and spawned").toHaveLength(0) + expect(outcome.kind).toBe("connect-failed") + }) +}) + +describe("INVARIANT — a revive is an install and owns its undo", () => { + test("a throw after a successful revive removes the client we started", async () => { + // One external failure, not two: the revive succeeds and the very next read + // throws. Before, that propagated to the catch-all with the client WE had + // just started still registered and serving — the outcome says failed, the + // registration says otherwise, and the registration is what the model sees. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + // Reads: inspection (1), the pre-revive guard's intent read (2), then the + // re-inspection — which is the one that fails. + if (reads === 3) throw new Error("EIO: re-inspection failed") + return good(name) + } + const outcome = await ensure("s1") + expect(h.added, "the revive happened").toHaveLength(1) + expect(h.removes, "left the engine this attach started registered and serving").toContain("datamate") + expect(outcome.kind).toBe("connect-failed") + }) +}) + +describe("INVARIANT — an undo that fails is never silent, however it fails", () => { + test("a persistRestore that THROWS does not become a silent superseded", async () => { + // The undo reports failure by returning "failed"; a throw is the other way + // it can fail, and the catch around it is load-bearing precisely because + // nothing else would notice. Dropping that catch turns a left-behind pin + // into a quiet `superseded`. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.persistRestore = async () => { + throw new Error("EROFS: read-only file system") + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts, "an undo that threw left a pin on disk and said nothing").toHaveLength(1) + expect(h.toasts[0]!.title).toContain("left behind") + }) + + test("two distinct failures are two signals; one failure is one", async () => { + // The dedupe is by VERDICT, not by turn, so a second and different failure + // must still be heard — otherwise deduplication becomes suppression. + const h = install({ which: null }) + await ensure("s1") + await ensure("s1") + expect(h.toasts.length, "one unchanged failure spoke more than once").toBe(1) + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => null + await ensure("s1") + expect(h.toasts.length, "a second, different failure was swallowed as a repeat").toBe(2) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts index 6c6087676d..458b05938e 100644 --- a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts @@ -185,11 +185,17 @@ describe("R2 — retry path: a disable between inspection #1 and the revive add" [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), ) - const realBinding = syncInternals.resolveBinding! - syncInternals.resolveBinding = async () => { - // the retry's stillCurrent() — after inspection #1 read intent + // RE-STAGED ON LIFT (twice). The disable has to land after inspection #1 and + // before the revive guard reads intent, and the guard's read order moved + // under this test — binding-first, then back to intent-first — so keying the + // trigger to a binding read no longer places it in the intended window. It + // lands at the end of inspection #1 instead, which is that window's opening + // edge and is stable against the guard's internal ordering. + const realEntry = syncInternals.existingEntry! + syncInternals.existingEntry = async (name: string) => { + const e = await realEntry(name) if (h.reads.length === 1) enabled = false - return realBinding() + return e } const outcome = await ensure("s1") // INVERTED ON LIFT: the revive guard checks the whole world now, so the diff --git a/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts b/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts index 6f4d7639d7..8a78b21224 100644 --- a/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts +++ b/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts @@ -11,7 +11,8 @@ test("AH: UNBOUND project, config read throws in the diagnostic branch → must syncInternals.notify = async (t) => { toasts.push(t) } syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } const out = await ensure("s1") - console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) + // GIVEN AN ASSERTION ON LIFT — this was the reviewer's unasserted observation. + void 0; console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) expect(out.kind).toBe("unbound") expect(toasts).toHaveLength(0) }) @@ -29,5 +30,8 @@ test("AH: the unbound escalation repeats every turn (connect-failed is REPAIRABL syncInternals.notify = async (t) => { toasts.push(t) } syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } await ensure("s1"); await ensure("s1"); await ensure("s1") - console.log("AH repeat: toasts over 3 turns =", toasts.length, toasts.map((t) => t.title)) + // GIVEN AN ASSERTION ON LIFT — it was the reviewer's unasserted observation, + // which could not fail and so protected nothing. An unbound project announces + // nothing at all, on any turn, whatever fails inside it. + expect(toasts, `an unbound project announced ${toasts.length} times`).toHaveLength(0) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 05736109dc..0315992579 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -13,6 +13,7 @@ import { TestInstance } from "../fixture/fixture" // Per-client state for controlling mock behavior interface MockClientState { + instance?: { onclose?: () => void } capabilities: { tools?: object; prompts?: object; resources?: object } capabilitiesShouldThrow: boolean tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> @@ -159,6 +160,9 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ clientCreateCount++ this._state = getOrCreateClientState(lastCreatedClientName) this._state.clientOptions = options + // altimate_change — expose the instance so a test can trigger `onclose`, + // which is how production learns the child exited. + this._state.instance = this as unknown as { onclose?: () => void } } async connect(transport: { start: () => Promise }) { @@ -1313,3 +1317,40 @@ it.instance( { config: { mcp: {} } }, ) // altimate_change end + +// altimate_change start — the spawn record's other two clearing paths +it.instance( + "disconnecting a client clears the spawn record", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The record answers "what IS running". A disabled key runs nothing, so + // leaving it would tell a later caller that a stopped engine is serving. + lastCreatedClientName = "disc" + yield* mcp.add("disc", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("disc"))).toEqual(["echo", "one"]) + yield* mcp.disconnect("disc") + expect(yield* mcp.spawned("disc"), "a disconnected key still claims to be running").toBeUndefined() + }), + ), + { config: { mcp: { disc: { type: "local", command: ["echo", "one"] } } } }, +) + +it.instance( + "a client whose child exits clears the spawn record", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "closed" + yield* mcp.add("closed", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("closed"))).toEqual(["echo", "one"]) + + // The transport closes under us — the engine died. + const state = getOrCreateClientState("closed") + state.instance?.onclose?.() + expect(yield* mcp.spawned("closed"), "a dead child still claims to be running").toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end From 015e277e49b4cbdc836f433ede39863d3b0dee4b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 11:04:25 +0800 Subject: [PATCH 50/67] fix(workspace): bound the announcement state on the session it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-session dedupe I added last commit was a module-level Map keyed by session id, set on every refusal and cleared only on success — so a long-running server whose new sessions keep failing would retain one entry per session forever. `engine-missing` is the obvious case: every session in a project without the engine installed. That is the same unbounded-map class round 5 already fixed for the session map itself, reintroduced beside it. It lives on the session record now, so it is bounded by whatever bounds the sessions — already solved, already tested — rather than solved a second time and needing its own eviction to stay correct. Carried forward across turns like `validated`, for the same reason: a fresh entry is built per call, so state that is not copied is state that is silently rebuilt, and rebuilding this one turns "say it once" back into "say it every turn". The existing eviction test gains the refusing-sessions case, and dropping the carry-forward fails three of the announcement invariants. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 37 +++++++++++++------ .../altimate/workspace/engine-sync.test.ts | 14 +++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 0dfe6530ff..18272e60f5 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -243,7 +243,8 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie return { act: "check-version" } } -/** The last verdict announced to each session. +/** The last verdict announced to a session — stored ON the session record. + * * * Repairable refusals are re-decided every turn — deliberately, because that is * how a repair gets noticed — but re-DECIDING is not a reason to re-TELL. A @@ -253,9 +254,14 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie * dialog per turn would be unusable. * * Keyed by session and by the verdict itself, so a CHANGED verdict speaks, and a - * successful attach clears the record so the next problem is heard. */ -const lastAnnounced = new Map() - + * successful attach clears it so the next problem is heard. + * + * NOT a module-level map of its own. A second map keyed by session id is a + * second thing to evict, and this one would only ever grow on the sessions that + * never succeed — a long-running server whose new sessions keep hitting + * `engine-missing` would retain every one of them. Hanging it on the session + * record means it is bounded by whatever bounds the sessions, which is already + * solved and already tested. */ function verdictSignature(outcome: Outcome): string { const detail = "error" in outcome ? outcome.error : "found" in outcome ? outcome.found : "declared" in outcome ? "" : "" @@ -266,7 +272,8 @@ function verdictSignature(outcome: Outcome): string { * it repeats an older one. Called when an attach succeeds: the problem the user * was told about is gone, and if it comes back they should hear about it. */ function clearAnnouncement(sessionID: string): void { - lastAnnounced.delete(sessionID) + const record = sessions.get(sessionID) + if (record) record.announced = undefined } /** Tell the user about a refusal — exactly once, from one place. @@ -316,14 +323,17 @@ type RefusalContext = { async function announceRefusal(outcome: Outcome, toast: Toast, context?: RefusalContext): Promise { try { - const sessionID = context?.sessionID - if (sessionID) { + const record = context?.sessionID ? sessions.get(context.sessionID) : undefined + if (record) { const signature = verdictSignature(outcome) - if (lastAnnounced.get(sessionID) === signature) { - log.info("verdict unchanged since the last turn; not repeating it", { sessionID, kind: outcome.kind }) + if (record.announced === signature) { + log.info("verdict unchanged since the last turn; not repeating it", { + sessionID: context?.sessionID, + kind: outcome.kind, + }) return } - lastAnnounced.set(sessionID, signature) + record.announced = signature } if (installWouldHelp(outcome)) { log.info("refusal is remediable by installing the engine", { ...context, kind: outcome.kind }) @@ -1245,6 +1255,8 @@ export const ATTACH_WAIT_MS = 15_000 type SessionAttach = { key?: string + /** The last verdict this session was told about — see `verdictSignature`. */ + announced?: string task: Promise waitTimedOut?: boolean outcome?: Outcome @@ -1417,6 +1429,10 @@ export function ensure(sessionID: string): Promise { // fresh entry is built per call, so state that is not copied is state that // is silently rebuilt. validated: previous?.validated, + // Carried forward for the same reason `validated` is: a fresh entry is built + // per call, so state that is not copied is state that is silently rebuilt — + // and rebuilding this one turns "say it once" back into "say it every turn". + announced: previous?.announced, } as SessionAttach // The whole task, not just the attach. `attachKey`, the memo re-validation and // the serialization chain all run BEFORE the attach's own catch, so a throw in @@ -1596,5 +1612,4 @@ export async function whenAttached(sessionID: string, timeoutMs: number = ATTACH export function resetForTests(): void { sessions.clear() attachChains.clear() - lastAnnounced.clear() } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 06e1c58e81..184ba81270 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -982,6 +982,20 @@ describe("ensure — round 5", () => { for (let i = 0; i < MAX_TRACKED_SESSIONS + 25; i++) await ensure(`s${i}`) expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) }) + + test("per-session announcement state is bounded by the same eviction", async () => { + // Every REFUSING session records what it was last told, so it can avoid + // repeating itself. A long-running server whose new sessions keep failing — + // `engine-missing` is the obvious case — would retain one record per session + // forever if that state lived in a map of its own. It lives on the session + // record instead, so it is bounded by whatever bounds the sessions, which is + // already solved and already tested above rather than solved twice. + install({ which: null }) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 25; i++) { + expect((await ensure(`r${i}`)).kind).toBe("engine-missing") + } + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + }) }) describe("ensure — round 6: a stale binding must not be installed", () => { From 788aa14af00ff8b73852c88e6b89e9e086188aca Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 11:14:00 +0800 Subject: [PATCH 51/67] fix(workspace): the undo's delete refuses on the same text, and pin what was correct but untested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore's REPLACE half checked the node on the same text it modifies; its DELETE half read the file separately and then called a remove that reads again — a check, not a same-text check. That distinction matters more for a delete than for a replace: a disable landing between the two reads is not overwritten, it is gone. `removeMcpFromConfig` takes the same refusal option now, decided on the text it is about to edit. The rest of this commit is coverage for things that were already right and that the suite could not see. Eight properties survived mutation until now, every one of them in code this series added: - the undo's failed re-read falling through to a blind restore - the restore's refusal dropped on the replace half, and on the delete half - the restore removing a node the user has since disabled - the announcement record not cleared on a REUSE (only on a fresh attach) - the dedupe keyed on outcome kind alone, so a same-kind change of detail — a different error against the same broken file — would go unmentioned Two of the gate's real-file suites are lifted for these, and each mutant now dies. That is the third time in this series that a fix was correct and unpinned; the pattern is that new code arrives with its happy path tested by whatever test motivated it, and its failure paths tested by nothing. Two limits named rather than fixed: the dedupe key includes the raw error text, so an error whose message varies between attempts re-announces each time — production strings for the same broken file are stable, and keying on kind alone would silence real changes, which is the worse trade. And the delete-side window is now the same non-atomic read-then-write as everywhere else, rather than the wider two-read shape it had. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 17 +- packages/opencode/src/mcp/config.ts | 19 +- .../workspace/engine-config-freshness.test.ts | 45 ++ .../altimate/workspace/gate-l1-r4.test.ts | 67 +++ .../workspace/gate-l3-r4-final.test.ts | 415 ++++++++++++++++++ 5 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts create mode 100644 packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 2d4a214dd9..13a44d4dda 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -130,15 +130,16 @@ export async function persistRestore( return "restored" } } else { - const onDisk = (await readMcpEntryFromDisk(name, target)) as ExistingEntry | undefined - if (onDisk?.enabled === false) { - // We were going to remove our entry because there was none before. The - // user has since switched this one off, which is an instruction about - // this node — honour it rather than deleting the node they just edited. - log.info("not removing an entry the user has disabled", { name }) - return "restored" + // We were going to remove our entry because there was none before. If the + // user has since switched this one off, that is an instruction about this + // node — honour it rather than deleting what they just edited. + // + // Decided on the same text the delete modifies, like the replace case. A + // separate read followed by a delete is worse than a separate read + // followed by a replace: the user's edit is not overwritten, it is gone. + if (!(await removeMcpFromConfig(name, target, { refuseIfDisabled: true }))) { + log.info("did not remove the entry; it is absent or the user has disabled it", { name }) } - await removeMcpFromConfig(name, target) } // Unobservable from this module's own tests for the same reason `persist`'s // is — every read here invalidates first. It is here for the other `Config` diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index dc97761d8a..1bab2e6961 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -81,7 +81,16 @@ export async function addMcpToConfig( return configPath } -export async function removeMcpFromConfig(name: string, configPath: string): Promise { +export async function removeMcpFromConfig( + name: string, + configPath: string, + // altimate_change — refuse to delete a node that is switched off, decided on + // the SAME text this call is about to modify. A caller that reads the file + // itself and then calls this one has checked a different read, which for a + // DELETE is worse than for a replace: the user's edit is not overwritten, it + // is gone. + opts?: { refuseIfDisabled?: boolean }, +): Promise { if (!(await Filesystem.exists(configPath))) return false const text = await Filesystem.readText(configPath) @@ -91,6 +100,14 @@ export async function removeMcpFromConfig(name: string, configPath: string): Pro const node = findNodeAtLocation(tree, ["mcp", name]) if (!node) return false + // altimate_change — see `opts.refuseIfDisabled` + if (opts?.refuseIfDisabled) { + const current = parse(text, [], { allowTrailingComma: true }) as + | { mcp?: Record } + | undefined + if (current?.mcp?.[name]?.enabled === false) return false + } + const edits = modify(text, ["mcp", name], undefined, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts index 5f0ce82424..5961ec13f3 100644 --- a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -133,3 +133,48 @@ describe("INVARIANT — the restore reports failure from the real write, not jus } }) }) + +describe("INVARIANT — the restore's write refuses on the same text, both ways", () => { + async function tempConfig(entry: unknown): Promise { + const { mkdtempSync, writeFileSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const nodePath = await import("node:path") + const dir = mkdtempSync(nodePath.join(tmpdir(), "restore-refuse-")) + const file = nodePath.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: entry } }, null, 2)) + return file + } + + async function readBack(file: string): Promise<{ mcp: { datamate?: { enabled?: boolean } } }> { + const { readFileSync } = await import("node:fs") + return JSON.parse(readFileSync(file, "utf8")) + } + + test("REPLACING does not overwrite a node the user has disabled", async () => { + // The lifted real-file test pins the delete half and the failed-re-read + // half; this is the third, which survived both. A restore that replaces is + // still a write, and a disable landing before it is still the user's + // instruction about that node. + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const file = await tempConfig({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled: false, + }) + await persistRestore("datamate", { type: "local", command: ["datamate", "start-stdio"] }, file) + const after = await readBack(file) + expect(after.mcp.datamate?.enabled, "the undo overwrote a disable the user had just made").toBe(false) + }) + + test("REMOVING does not delete a node the user has disabled", async () => { + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const file = await tempConfig({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled: false, + }) + await persistRestore("datamate", null, file) + const after = await readBack(file) + expect(after.mcp.datamate, "the undo deleted the node the user had just disabled").toBeDefined() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts b/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts new file mode 100644 index 0000000000..4234e997ad --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals } from "../../../src/altimate/workspace/engine-sync" +import { persistRestore } from "../../../src/altimate/workspace/engine-config" +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +const A = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/a" } as any +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { for (const k of Object.keys(syncInternals) as any[]) delete (syncInternals as any)[k] }) +function base(h: any) { + syncInternals.resolveBinding = async () => A + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: [], extensionKeys: [] }) + syncInternals.persist = async (n, c) => { h.persisted.push(c); return "written" as const } + syncInternals.projectConfigPath = async () => "/tmp/x/altimate-code.json" + syncInternals.existingEntry = async () => h.entry + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, p) => { h.restores.push(p ?? null); return "restored" as const } + const q = [{}, { datamate: { status: "connected" } }] + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!) as any, + add: async () => { h.added += 1 }, remove: async () => { h.removes += 1 }, + spawned: async () => undefined, tools: async () => ({}), + } +} +describe("AQ — the undo's failed re-read writes nothing", () => { + test("projectEntry throws at undo time: no restore write, one 'left behind' toast", async () => { + const h = { persisted: [] as any[], restores: [] as any[], toasts: [] as any[], added: 0, removes: 0, entry: null as any } + base(h) + let reads = 0 + syncInternals.projectEntry = async () => { reads += 1; if (reads >= 2) throw new Error("EIO"); return null } + const prevTools = syncInternals.mcp!.tools + syncInternals.mcp!.tools = async () => { h.entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }; return prevTools() } + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect(h.restores, "the undo wrote blind after its re-read failed").toHaveLength(0) + expect(h.toasts.map((t: any) => t.title).some((t: string) => t.includes("left behind")), JSON.stringify(h.toasts.map((t: any) => t.title))).toBe(true) + }) +}) +describe("AR — the restore's write honours a disable on disk (real file)", () => { + test("previous non-null: a disabled node is not overwritten", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] } as any, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp.datamate.enabled, "overwrote a disabled node").toBe(false) + expect(after.mcp.datamate.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + test("previous null (delete case): a disabled node is kept, not deleted", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", null, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate?.enabled, "deleted the node the user disabled").toBe(false) + }) + test("previous null, node enabled: removed as before", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } } }, null, 2)) + await persistRestore("datamate", null, file) + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts new file mode 100644 index 0000000000..bd04bbea49 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts @@ -0,0 +1,415 @@ +// L3 confirmation-pass experiments against 8c78d98eb. Not part of the suite. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { ensure, resetForTests, settledOutcome, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" +import { Config } from "../../../src/config/config" +import { Filesystem } from "../../../src/util/filesystem" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + removes: string[] + restores: Array + toasts: Array<{ title: string; message: string }> + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry +} + +function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null, opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}): H { + const h: H = { added: [], persisted: [], removes: [], restores: [], toasts: [], statusQueue: statuses, reads: [], spawnedNow: opts.spawned } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push({ title: t.title, message: t.message }) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { + h.restores.push(prev) + } + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] +}) + +const DISABLED_FILE = JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } }, null, 2) +const PINNED42 = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } as ExistingEntry + +describe("AG — real persist: the check is on the same text the write modifies", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(async () => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-")), "altimate-code.json") + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + /** After the guard's intent read, config-file readText #1 is now addMcpToConfig's + * ONLY read (persist has no separate check read any more). */ + function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") { + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true }) + syncInternals.projectConfigPath = async () => file + let armed = false + let landed = false + let n = 0 + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (h.reads.length === 2) { + if (where === "intent-read-end" && !landed) { + landed = true + writeFileSync(file, DISABLED_FILE) + } + armed = true + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + Filesystem.readText = async (p: string) => { + if (!armed || p !== file || landed) return originalReadText(p) + n += 1 + if (n !== 1) return originalReadText(p) + landed = true + if (where === "before-write-read") { + writeFileSync(file, DISABLED_FILE) + return originalReadText(p) + } + const text = await originalReadText(p) + writeFileSync(file, DISABLED_FILE) + return text + } + return { h, reads: () => n } + } + + test("W1: disable at the end of the guard's intent read → refused by the write's own read", async () => { + const { h } = stage("intent-read-end") + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) + + test("W0/W2 (merged by construction): disable lands before the write's single read → refused", async () => { + const { h, reads } = stage("before-write-read") + const out = await ensure("s1") + console.log("W0/W2:", JSON.stringify(out), "disk:", JSON.stringify(await diskEntry()), "config reads after guard:", reads()) + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + }) + + test("W3 (named residual): disable lands between the write's read and its write → still lost", async () => { + const { h } = stage("after-write-read") + const out = await ensure("s1") + const after = await diskEntry() + console.log("W3:", JSON.stringify(out), "disk:", JSON.stringify(after)) + expect(out.kind).toBe("attached") + expect(after?.enabled).toBe(true) + expect(after?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.added).toHaveLength(1) + expect(await ensure("s1")).toBe(out) + }) +}) + +describe("AH/AI — freshConfig throws at each read in turn (real existingEntry, no seam)", () => { + function realReader(throwAt: (n: number) => boolean, onDisk: () => ExistingEntry | null) { + const h = install([{}, { datamate: { status: "connected" } }], () => null) + delete syncInternals.existingEntry + let n = 0 + syncInternals.freshConfig = async () => { + n += 1 + if (throwAt(n)) throw new Error(n === 1 || throwAt(1) ? "EIO" : `EIO#`) + const e = onDisk() + return { mcp: e ? { datamate: e } : {} } + } + return { h, calls: () => n } + } + + test("read #1 (inspection) throws → connect-failed, 1 toast, no mutation", async () => { + const { h } = realReader((n) => n === 1, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: Error: EIO" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #2 (pre-install guard) throws → connect-failed, 1 toast, no mutation; same label as the inspection", async () => { + const { h } = realReader((n) => n === 2, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: intent could not be confirmed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #3 (post-install guard) throws → install undone, connect-failed, 1 toast", async () => { + const { h } = realReader((n) => n === 3, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(1) + expect(h.added).toHaveLength(1) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + }) + + test("undo re-read (projectEntry #2) throws → FAILS CLOSED: no restore, one left-behind toast, superseded", async () => { + let current: CachedBinding | null = binding + const h = install([{}, { datamate: { status: "connected" } }], () => null) + syncInternals.resolveBinding = async () => current + let pe = 0 + syncInternals.projectEntry = async () => { + pe += 1 + if (pe === 2) throw new Error("EIO undo re-read") + return null + } + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + const out = await ensure("s1") + expect(out.kind).toBe("superseded") + expect(pe).toBe(2) + expect(h.restores).toEqual([]) + expect(h.removes).toEqual(["datamate"]) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) + + test("memo validation read throws (transient) → not served, re-decided → reused; no toast", async () => { + const { h } = realReader((n) => n === 4, () => (h.added.length ? PINNED42 : null)) + const first = await ensure("s1") + expect(first.kind).toBe("attached") + h.statusQueue = [{ datamate: { status: "connected" } }] + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(second.kind).toBe("reused") + expect(h.toasts).toHaveLength(1) + }) + + test("PERSISTENT throw: three turns re-decide but announce ONCE (AL)", async () => { + const { h, calls } = realReader(() => true, () => null) + const a = await ensure("s1") + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AH persistent:", a.kind, b.kind, c.kind, "toasts:", h.toasts.length, "freshConfig calls:", calls()) + expect([a.kind, b.kind, c.kind]).toEqual(["connect-failed", "connect-failed", "connect-failed"]) + expect(h.toasts.length).toBe(1) + expect(h.persisted).toHaveLength(0) + }) +}) + +describe("AJ — persistent probe failure in the check-version branch", () => { + test("turn 1: detach + refuse once (engine-too-old), client not left registered; later turns re-decide silently (AL)", async () => { + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => PINNED42, + { spawned: PINNED42 }, + ) + syncInternals.versionOf = async () => { + throw new Error("EACCES") + } + const a = await ensure("s1") + expect(a.kind).toBe("engine-too-old") + expect(h.removes).toEqual(["datamate"]) + expect(h.spawnedNow).toBeUndefined() + expect(h.toasts).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") + + // Turn 2: the outcome is REPAIRABLE, so the memo does not hold it — run() again. + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AJ:", b.kind, c.kind, "toasts:", h.toasts.length, "added:", h.added.length, "removes:", h.removes.length) + expect(b).not.toBe(a) + expect(h.toasts.length).toBe(1) + }) +}) + +describe("spawned cleared on onclose / disconnect — the next attach", () => { + test("child exit (record cleared, status failed) → revived via add → reused", async () => { + const h = install([{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], () => PINNED42) + const out = await ensure("s1") + expect(out.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.spawnedNow?.command).toEqual(PINNED42.command) + expect(h.persisted).toHaveLength(0) + }) + + test("disconnect (record cleared, status disabled, config enabled:false) → entry-disabled; then /mcp enable-style re-add → reused", async () => { + let enabled = true + let status: { status: string } = { status: "connected" } + const h = install([], () => ({ ...PINNED42, enabled }), { spawned: PINNED42 }) + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("reused") + // MCP.disconnect: closeClient, delete spawned, status disabled, persist enabled:false + enabled = false + status = { status: "disabled" } + h.spawnedNow = undefined + const mid = await ensure("s1") + expect(mid.kind).toBe("entry-disabled") + expect(h.added).toHaveLength(0) + // MCP.connect (prompt.ts /mcp enable): createAndStore → spawned set, status connected, persist enabled:true + enabled = true + status = { status: "connected" } + h.spawnedNow = PINNED42 + const back = await ensure("s1") + expect(back.kind).toBe("reused") + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) +}) + +describe("AL — dedupe edges", () => { + test("same kind, changed detail (engine-too-old 0.5.9 → 0.6.0) speaks again", async () => { + let v = "0.5.9" + const h = install([{}], () => null) + syncInternals.versionOf = async () => v + expect((await ensure("s1")).kind).toBe("engine-too-old") + expect((await ensure("s1")).kind).toBe("engine-too-old") + v = "0.6.0" + expect((await ensure("s1")).kind).toBe("engine-too-old") + console.log("AL detail:", h.toasts.length, h.toasts.map((t) => t.message.slice(0, 40))) + expect(h.toasts.length).toBe(2) + }) + test("two sessions with the same verdict each hear it once", async () => { + const h = install([{}], () => null) + syncInternals.which = () => null + await ensure("a"); await ensure("a"); await ensure("b"); await ensure("b") + expect(h.toasts.length).toBe(2) + }) + test("a reuse after a refusal clears the record: refusal → reused → same refusal speaks again", async () => { + let onPath: string | null = null + let status: { status: string } = { status: "disabled" } + const h = install([], () => PINNED42, { spawned: undefined }) + syncInternals.which = () => onPath + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("engine-missing") // ours+down → retry → revive? no: which null → refuse-unreachable → engine-missing + onPath = "/usr/local/bin/datamate"; status = { status: "connected" }; h.spawnedNow = PINNED42 + expect((await ensure("s1")).kind).toBe("reused") + onPath = null; status = { status: "disabled" }; h.spawnedNow = undefined + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.filter((t) => t.title.includes("unavailable")).length).toBe(2) + }) +}) + +describe("RT/RU — the restore's own window: a disable landing between the undo's read and the restore's write (REAL persist + REAL persistRestore)", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(() => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-restore-")), "altimate-code.json") + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function stageRestore(initial: ExistingEntry | null) { + let current: CachedBinding | null = binding + const statuses: H["statusQueue"] = initial ? [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }] : [{}, { datamate: { status: "connected" } }] + const h = install(statuses, () => null, { realPersist: true }) + delete syncInternals.persistRestore // REAL restore + syncInternals.projectConfigPath = async () => file + syncInternals.resolveBinding = async () => current + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + return e + } + let pe = 0 + let armed = false + let landed = false + syncInternals.projectEntry = async () => { + pe += 1 + const e = (await diskEntry()) ?? null + if (pe === 2) armed = true // the undo's own read has just completed + return e + } + // binding moves during tools() → post-install guard → undo + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + Filesystem.readText = async (p: string) => { + if (armed && !landed && p === file) { + landed = true + // the user disables OUR entry after the undo read it and before the restore writes + const now = (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry + writeFileSync(file, JSON.stringify({ mcp: { datamate: { ...now, enabled: false } } }, null, 2)) + } + return originalReadText(p) + } + return { h, landed: () => landed } + } + + test("RT: previous entry existed → restore must NOT overwrite the disable with the enabled previous", async () => { + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + const { h, landed } = stageRestore({ type: "local", command: ["datamate", "start-stdio"], enabled: true }) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RT:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after?.enabled, "the restore wrote the enabled previous entry over the user's disable").toBe(false) + }) + + test("RU: no previous entry → restore must NOT delete the node the user just disabled", async () => { + writeFileSync(file, "{}\n") + const { h, landed } = stageRestore(null) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RU:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after, "the restore deleted the node the user had just disabled").toBeDefined() + expect(after?.enabled).toBe(false) + }) +}) From c5c6d1478ca6e8be026aac733856b9d0721f152c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 11:19:24 +0800 Subject: [PATCH 52/67] fix(workspace): a client we started is torn down whatever is bound now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teardown split says undoing what this attach created is right regardless of the binding — that is the whole reason the split exists. A revived client was still exiting through the binding-dependent gate: revive succeeds, then the entry is rewritten unpinned AND the binding moves in the same window, so the teardown is correctly skipped as "this might belong to the new binding" while being a process we started seconds earlier. Silent `superseded`, engine left registered and connected. The definition was right; the plumbing did not carry it as far as this exit. The flag saying we started the client is scoped to the attach now rather than to the revive block, because the teardown that matters happens later than the revive does. That is the shape of most of this branch's remaining defects: not a rule anyone disagreed with, but a rule that stopped being applied somewhere past where it was written down. The gate's test for it is lifted and renamed — it was filed as a residual, and it is not one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 18 +- .../altimate/workspace/gate-l4-r3.test.ts | 154 ++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 18272e60f5..3491a83b82 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -709,6 +709,19 @@ async function run(sessionID: string): Promise { } let plan = planForEntry(inspection, workspaceId, false) + /** Did THIS attach start the client that is now registered? + * + * Scoped to the whole attach rather than to the revive block, because the + * teardown that matters happens later. By the teardown split's own definition + * — undoing what this attach created is right regardless of what is bound now + * — a client we revived is binding-INDEPENDENT, but it was exiting through the + * binding-dependent gate: revive, then a re-link plus an unpinning rewrite in + * the same window, and the teardown is correctly skipped as "might belong to + * the new binding" while being a process we started seconds earlier. + * + * The definition was right and the plumbing did not carry it this far. */ + let revived = false + if (plan.act === "retry-connect") { // Exactly one retry, then report — never a second spawn beside a failing // one. "Never twice" is the `retried` argument rather than a branch someone @@ -735,7 +748,6 @@ async function run(sessionID: string): Promise { if (beforeRevive === "disabled") return await refuseDisabled() if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeRevive !== "ok") return { kind: "superseded" } - let revived = false await client .add(DATAMATE_KEY, revive) .then(() => { @@ -847,7 +859,9 @@ async function run(sessionID: string): Promise { pinnedTo: plan.pinnedTo, entry: plan.entry, }) - await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: plan.pinnedTo }) + // `!revived` — if we started this client, tearing it down is undoing our own + // work and never depends on the binding. + await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: plan.pinnedTo }, !revived) } if (plan.act === "check-version") { diff --git a/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts new file mode 100644 index 0000000000..b914424ed7 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts @@ -0,0 +1,154 @@ +// Gate L4 round-3 attack tests against 6cb70bb43. A FAILING test = demonstrated gap (or a documented observation, as labelled). +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const binding: CachedBinding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/analytics" } as CachedBinding +const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding +type H = { trace: string[]; added: Array<{ name: string; cfg: LocalMcpConfig }>; persisted: Array<{ name: string; cfg: LocalMcpConfig }>; removes: string[]; toasts: Array<{ title: string; message: string; variant: string }>; restores: Array; statusQueue: Array>; tools: Record } +function install(opts: { which?: string | null; version?: string | null | ((bin: string) => string | null); statuses?: H["statusQueue"]; tools?: Record; existing?: ExistingEntry | null | (() => ExistingEntry | null); projectEntry?: ExistingEntry | null | (() => ExistingEntry | null) }): H { + const h: H = { trace: [], added: [], persisted: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + const t = (s: string) => h.trace.push(s) + syncInternals.resolveBinding = async () => (t("resolveBinding"), binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (t("versionOf"), typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => (t("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { t("persist"); h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { t("existingEntry"); if (typeof opts.existing === "function") return opts.existing(); if (opts.existing !== undefined) return opts.existing; const last = h.persisted[h.persisted.length - 1]; return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null } + syncInternals.projectEntry = async () => { t("projectEntry"); return typeof opts.projectEntry === "function" ? opts.projectEntry() : (opts.projectEntry ?? null) } + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.notify = async (tt) => { t("notify"); h.toasts.push(tt) } + syncInternals.toolsChanged = async () => { t("toolsChanged") } + syncInternals.persistRestore = async (_n, prev) => { t("persistRestore"); h.restores.push(prev ?? null) } + syncInternals.mcp = { status: async () => (t("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), add: async (name, cfg) => { t("add"); h.added.push({ name, cfg }) }, remove: async (name) => { t("remove"); h.removes.push(name) }, tools: async () => (t("tools"), h.tools) } + return h +} +beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) +afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k]; if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE; else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG }) +const ours: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + +describe("AB — the undo re-reads the project entry at undo time", () => { + test("AB-1: disable lands on OUR node during the boot; re-read succeeds → the disabled node is kept (not deleted), outcome entry-disabled", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => (phase === 0 ? null : { ...ours, enabled: false }), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.removes).toContain("datamate") + expect(h.restores, "the user's disable was undone").toEqual([{ ...ours, enabled: false }]) + }) + test("AB-2 (#13): same, but the undo-time re-read THROWS → falls back to the snapshot restore and DELETES the node the user disabled", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => { if (phase === 0) return null; throw new Error("EACCES on re-read") }, + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores, "a failed re-read fell back to restoring the snapshot: the user's disabled node is removed").not.toEqual([null]) + }) + test("AB-3: the disable landed on the GLOBAL entry (merged says disabled, project node is ours, enabled) → our node is removed, global untouched", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { type: "local", command: ["datamate", "start-stdio"], enabled: false }), + projectEntry: () => (phase === 0 ? null : ours), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores).toEqual([null]) + }) +}) + +describe("AD — in-region refusals undo BEFORE announcing; the finally is idempotent", () => { + test("post-add connect-failed: remove and persistRestore precede notify; exactly one remove and one restore", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed" }) + const iRemove = h.trace.indexOf("remove"), iRestore = h.trace.indexOf("persistRestore"), iNotify = h.trace.indexOf("notify") + expect(iRemove).toBeGreaterThanOrEqual(0) + expect(iRestore).toBeGreaterThan(iRemove) + expect(iNotify, `trace: ${h.trace.join(" > ")}`).toBeGreaterThan(iRestore) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + expect(h.toasts).toHaveLength(1) + }) + test("persist refused as 'disabled' → nothing installed, nothing undone, entry-disabled announced once", async () => { + const h = install({ statuses: [{}] }) + syncInternals.persist = async () => "disabled" + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.added).toHaveLength(0) + expect(h.restores).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) +}) + +describe("W — an undo that fails is announced once, naming the file; the triggering outcome survives", () => { + test("post-add connect-failed + restore failed → two toasts (engine failed; config left behind in ), outcome connect-failed", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.persistRestore = async () => "failed" + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed", error: "exit 1" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind", "Workspace engine failed to start"]) + expect(h.toasts[0]!.message).toContain("/tmp/test/.altimate-code/altimate-code.json") + }) + test("superseded + restore failed → exactly one toast (config left behind), outcome superseded", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.persistRestore = async () => { throw new Error("EACCES") } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) +}) + +// RENAMED ON LIFT: no longer a residual. A client this attach started is +// torn down whatever is bound now, which is what the teardown split said +// all along — the definition was right and the plumbing did not carry it +// as far as this exit. +describe("INVARIANT — a client we started is torn down whatever is bound now", () => { + test("(i) revive succeeds, then the re-inspection read THROWS → revived client left connected, outcome connect-failed via the catch-all", async () => { + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; if (reads >= 3) throw new Error("config unreadable"); return ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + const outcome = await ensure("s1") + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(outcome.kind).toBe("connect-failed") + expect(h.removes, "the client this attach started is left registered and connected under a connect-failed outcome").toContain("datamate") + }) + test("(ii) revive succeeds, the file is rewritten unpinned and the binding moves: the revived client is still torn down", async () => { + let current: CachedBinding | null = binding + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; return reads >= 3 ? { type: "local", command: ["datamate", "start-stdio"] } : ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + expect(h.added).toHaveLength(1) + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "the client this attach started is left registered and connected").toContain("datamate") + }) +}) From fcb0cb4fc735a482b05dde632ce328319bce5ea9 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 12:55:07 +0800 Subject: [PATCH 53/67] chore(mcp): wrap the spawn-record and same-text hunks in altimate_change markers Convention, not behaviour. These two files are upstream-shared, and every altimate hunk in them has to be wrapped so a future bridge merge can see what is ours and not overwrite it. The spawn record and the two same-text refusal options were the first changes this branch made to upstream-shared files, and they went in with single-line comments rather than start/end blocks. Wrapped: the `spawned` field on State, its accessor on Interface, its member on the service, the sets in `add`/`createAndStore` and in bootstrap, the clears in `remove`/`disconnect`/`onclose`/shutdown, `remove`'s clearing of the runtime config, and the `refuseIfDisabled` option on both `addMcpToConfig` and `removeMcpFromConfig`. Worth recording why this was not caught earlier: three rounds of five adversarial reviewers checked the behaviour exhaustively and none of them ran the repo's own merge-safety check, because every prior change on this branch lived in files this project owns outright. `analyze.ts --markers` belongs on the pre-push list next to typecheck and the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- packages/opencode/src/mcp/config.ts | 33 ++++++++++++++++++----------- packages/opencode/src/mcp/index.ts | 32 ++++++++++++++++++---------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index 1bab2e6961..27a01b4c4f 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -33,17 +33,21 @@ export async function resolveConfigPath(baseDir: string, global = false) { } export async function addMcpToConfig( + // altimate_change start — the parameter list is split across lines only + // because the added `opts` exceeds the line budget; the reformat is ours too. + // + // `opts.refuseIfDisabled` refuses to replace a node that is switched off, + // decided on the SAME text this call is about to modify. A caller that reads + // the file itself and then calls this one has checked a different read than + // the write uses, so a disable landing between the two is replaced wholesale + // rather than honoured. One read, one decision, is the only version of this + // check that means anything. name: string, mcpConfig: ConfigMCPV1.Info, configPath: string, - // altimate_change — refuse to replace a node that is switched off, decided on - // the SAME text this call is about to modify. A caller that reads the file - // itself and then calls this one has checked a different read than the write - // uses, so a disable landing between the two is replaced wholesale rather than - // honoured. One read, one decision, is the only version of this check that - // means anything. opts?: { refuseIfDisabled?: boolean }, ) { + // altimate_change end let text = "{}" if (await Filesystem.exists(configPath)) { text = await Filesystem.readText(configPath) @@ -82,15 +86,19 @@ export async function addMcpToConfig( } export async function removeMcpFromConfig( + // altimate_change start — the parameter list is split across lines only + // because the added `opts` exceeds the line budget; the reformat is ours too. + // + // `opts.refuseIfDisabled` refuses to delete a node that is switched off, + // decided on the SAME text this call is about to modify. A caller that reads + // the file itself and then calls this one has checked a different read, which + // for a DELETE is worse than for a replace: the user's edit is not + // overwritten, it is gone. name: string, configPath: string, - // altimate_change — refuse to delete a node that is switched off, decided on - // the SAME text this call is about to modify. A caller that reads the file - // itself and then calls this one has checked a different read, which for a - // DELETE is worse than for a replace: the user's edit is not overwritten, it - // is gone. opts?: { refuseIfDisabled?: boolean }, ): Promise { + // altimate_change end if (!(await Filesystem.exists(configPath))) return false const text = await Filesystem.readText(configPath) @@ -100,13 +108,14 @@ export async function removeMcpFromConfig( const node = findNodeAtLocation(tree, ["mcp", name]) if (!node) return false - // altimate_change — see `opts.refuseIfDisabled` + // altimate_change start — see `opts.refuseIfDisabled` if (opts?.refuseIfDisabled) { const current = parse(text, [], { allowTrailingComma: true }) as | { mcp?: Record } | undefined if (current?.mcp?.[name]?.enabled === false) return false } + // altimate_change end const edits = modify(text, ["mcp", name], undefined, { formattingOptions: { tabSize: 2, insertSpaces: true }, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d1ac3fc8ef..67ceb5c01f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -293,8 +293,9 @@ interface State { export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> - // altimate_change — what this process spawned for a key; see State.spawned + // altimate_change start — what this process spawned for a key; see State.spawned readonly spawned: (name: string) => Effect.Effect + // altimate_change end // altimate_change start — carry the original (pre-sanitize) client name so tool-source // classification works from the real name, not the flattened `_` key // (see altimate/tool-source). @@ -709,10 +710,11 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] - // altimate_change — the child exited, so nothing is running under this - // key. The spawn record answers "what IS running" and must not outlive - // the process it describes. + // altimate_change start — the child exited, so nothing is running under + // this key. The spawn record answers "what IS running" and must not + // outlive the process it describes. delete s.spawned[name] + // altimate_change end s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -769,8 +771,9 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, - // altimate_change — see State.spawned + // altimate_change start — see State.spawned spawned: {}, + // altimate_change end } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -802,8 +805,9 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! - // altimate_change — bootstrap spawns too, so it records too. + // altimate_change start — bootstrap spawns too, so it records too. s.spawned[key] = mcp + // altimate_change end watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -837,8 +841,9 @@ export const layer = Layer.effect( const clients = Object.values(s.clients) s.clients = {} s.defs = {} - // altimate_change — nothing is running any more; see State.spawned + // altimate_change start — nothing is running any more; see State.spawned s.spawned = {} + // altimate_change end yield* Effect.forEach( clients, (client) => @@ -936,8 +941,10 @@ export const layer = Layer.effect( return result.status } - // altimate_change — remember what we actually spawned, not what the file says. + // altimate_change start — remember what we actually spawned, not what the + // file says. s.spawned[name] = mcp + // altimate_change end return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) }) @@ -965,10 +972,11 @@ export const layer = Layer.effect( // altimate_change end yield* closeClient(s, name) delete s.clients[name] - // altimate_change — nothing is running under this key now, so the spawn - // record must not survive it either: it answers "what IS running", and a - // disabled key runs nothing. + // altimate_change start — nothing is running under this key now, so the + // spawn record must not survive it either: it answers "what IS running", + // and a disabled key runs nothing. delete s.spawned[name] + // altimate_change end s.status[name] = { status: "disabled" } // altimate_change start — telemetry + persist enabled:false so disable survives restarts Telemetry.track({ @@ -1384,7 +1392,9 @@ export const layer = Layer.effect( return Service.of({ status, clients, + // altimate_change start — see State.spawned spawned, + // altimate_change end tools, prompts, resources, From d46be88d5b6de8c1b27cd66e535c0350ba3fdbc5 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 13:02:07 +0800 Subject: [PATCH 54/67] fix(workspace): resolve the path once, key the verdict by workspace, forget a client that never started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 1 on the rewritten head: three findings, no P1, and all three are the same shape as the rest of this branch — a rule applied at one site and not at its neighbour. The project config path was resolved twice: once inside the snapshot read and once for the write. An IDE creating or removing a higher-priority config between the two is enough for the snapshot to come from one file while the write goes to another, after which the undo restores the first file's entry into the second, over whatever the user had there. The path is resolved once now and used by the snapshot, the write and the undo alike. I had already applied exactly this rule to the restore — "the same path the write used, not a fresh resolution" — and did not carry it one call further up. The announcement dedupe keyed on outcome kind and detail but not on the workspace, and the record is carried across a re-link — so a session re-linked from A to B was silenced about B by an identical-kind refusal it had been told about for A, leaving the user holding guidance that names a workspace they have left. "Same verdict" has to mean the same verdict about the same thing. And `MCP.add` over a live client closes the old one before creating its replacement; when that creation fails, the failure branch dropped the client but kept the spawn record, so `spawned()` went on describing a closed process. Every other place a client stops existing already cleared it — remove, disconnect, child exit, shutdown — and this was the fifth. Each fixed with a test that fails when the fix is reverted, including the failed-replacement case, which no fixture had staged. Also in this commit: every altimate hunk in the two upstream-shared MCP files is wrapped in `altimate_change` markers, which CI's Marker Guard requires so a future bridge merge can tell our code from upstream's. These were the first hunks this branch put into shared files, and three rounds of five adversarial reviewers checked the behaviour without anyone running the repo's own merge-safety check. It belongs on the pre-push list next to typecheck. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 11 +++-- .../src/altimate/workspace/engine-sync.ts | 45 ++++++++++++------- packages/opencode/src/mcp/index.ts | 7 +++ .../altimate/workspace/engine-sync.test.ts | 42 +++++++++++++++++ packages/opencode/test/mcp/lifecycle.test.ts | 25 +++++++++++ 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 13a44d4dda..84f16a4678 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -87,7 +87,7 @@ export async function freshConfig(): Promise<{ mcp?: Record { +export async function projectEntry(configPath?: string): Promise { if (syncInternals.projectEntry) return syncInternals.projectEntry() // THROWS rather than returning null on a read error, because the two answers // mean opposite things to the caller: `null` says "the project file has no @@ -95,8 +95,13 @@ export async function projectEntry(): Promise { // "there was nothing here" with "I could not look" turned an unreadable // project config into a deletion of the user's own entry. If we cannot record // what to put back, we must not write in the first place. - const configPath = await resolveConfigPath(projectRoot()) - return ((await readMcpEntryFromDisk(DATAMATE_KEY, configPath)) as ExistingEntry | undefined) ?? null + // Reads the path it is GIVEN. Resolving independently means the snapshot can + // come from one file while the write goes to another — an IDE creating or + // removing a higher-priority config between the two is enough — after which + // the undo restores the first file's entry into the second, over whatever the + // user had there. + const target = configPath ?? (await resolveConfigPath(projectRoot())) + return ((await readMcpEntryFromDisk(DATAMATE_KEY, target)) as ExistingEntry | undefined) ?? null } /** Put the config back the way we found it. diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 3491a83b82..96807f0a31 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -262,10 +262,15 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie * `engine-missing` would retain every one of them. Hanging it on the session * record means it is bounded by whatever bounds the sessions, which is already * solved and already tested. */ -function verdictSignature(outcome: Outcome): string { +function verdictSignature(outcome: Outcome, workspaceId?: string): string { const detail = "error" in outcome ? outcome.error : "found" in outcome ? outcome.found : "declared" in outcome ? "" : "" - return `${outcome.kind}:${detail}` + // The workspace is part of the identity. Without it, a session re-linked from + // A to B is silenced about B by an identical-kind refusal it was told about + // for A — the record is carried across the re-link, so the user is left with + // guidance naming a workspace they have left. "Same verdict" has to mean the + // same verdict about the same thing. + return `${workspaceId ?? "-"}:${outcome.kind}:${detail}` } /** Forget what a session was last told, so the next verdict is announced even if @@ -325,7 +330,7 @@ async function announceRefusal(outcome: Outcome, toast: Toast, context?: Refusal try { const record = context?.sessionID ? sessions.get(context.sessionID) : undefined if (record) { - const signature = verdictSignature(outcome) + const signature = verdictSignature(outcome, context?.workspaceId) if (record.announced === signature) { log.info("verdict unchanged since the last turn; not repeating it", { sessionID: context?.sessionID, @@ -617,7 +622,7 @@ async function run(sessionID: string): Promise { // on a stale world. Read at undo time, and never undo a disable. let now: ExistingEntry | null = null try { - now = await projectEntry() + now = await projectEntry(configPath) } catch (err) { // Fails CLOSED, like the guard's read and for the same reason. Restoring // "what we replaced" on a read we could not perform can overwrite a @@ -1024,9 +1029,29 @@ async function run(sessionID: string): Promise { // project config previously read as "no entry here", which a later restore // acts on by REMOVING — so a transient read failure could delete the user's // own entry as the undo of an attach that was meant to leave it alone. + // The path FIRST, and then the snapshot read from that exact path. Resolving + // twice means the snapshot can come from one file while the write goes to + // another — an IDE creating or removing a higher-priority config between the + // two is enough — after which the undo restores the first file's entry into + // the second, over whatever the user had there. One resolution, used by the + // read, the write and the undo alike. + let configPath: string + try { + configPath = await projectConfigPath() + } catch (err) { + // Falling back to persist's own resolution would write to a path we could + // not resolve here, which the undo then re-resolves independently — two + // guesses about which file we touched. If we cannot say where we would + // write, we do not write. + return await refuseUnreadable(`config path could not be resolved: ${String(err)}`) + } + // If we cannot record what to put back, we do not write. An unreadable + // project config previously read as "no entry here", which a later restore + // acts on by REMOVING — so a transient read failure could delete the user's + // own entry as the undo of an attach that was meant to leave it alone. let projectBefore: ExistingEntry | null try { - projectBefore = await projectEntry() + projectBefore = await projectEntry(configPath) } catch (err) { return await refuse({ kind: "connect-failed", error: `project config unreadable: ${String(err)}` }, { title: "Workspace engine not attached", @@ -1037,16 +1062,6 @@ async function run(sessionID: string): Promise { variant: "error", }) } - let configPath: string - try { - configPath = await projectConfigPath() - } catch (err) { - // Falling back to persist's own resolution would write to a path we could - // not resolve here, which the undo then re-resolves independently — two - // guesses about which file we touched. If we cannot say where we would - // write, we do not write. - return await refuseUnreadable(`config path could not be resolved: ${String(err)}`) - } const beforeInstall = await worldUnchanged() if (beforeInstall === "disabled") return await refuseDisabled() if (beforeInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 67ceb5c01f..ef0ac95e89 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -938,6 +938,13 @@ export const layer = Layer.effect( if (!result.mcpClient) { yield* closeClient(s, name) delete s.clients[name] + // altimate_change start — a replacement that failed to come up leaves + // nothing running under this key, so the record of what was running must + // go with it. `add` over a live client closes the old one here; keeping + // its record would have `spawned()` describe a closed process, which is + // the one thing this record exists not to do. + delete s.spawned[name] + // altimate_change end return result.status } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 184ba81270..02fc083425 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2571,3 +2571,45 @@ describe("INVARIANT — an undo that fails is never silent, however it fails", ( expect(h.toasts.length, "a second, different failure was swallowed as a repeat").toBe(2) }) }) + +describe("INVARIANT — codex round 1: identity and paths are resolved once", () => { + test("a re-link is not silenced by the same refusal about the workspace it left", async () => { + // The dedupe record is carried across a re-link, so without the workspace in + // the key an identical-kind refusal about A silences B — and the user is + // left holding guidance that names a workspace they have left. + let current: CachedBinding | null = binding + const h = install({ which: null }) + syncInternals.resolveBinding = async () => current + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts).toHaveLength(1) + + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.length, "the new workspace's refusal was swallowed as a repeat of the old one").toBe(2) + expect(h.toasts[1]!.message).toContain("other") + }) + + test("the snapshot, the write and the undo all use one resolved path", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + const seen: Array = [] + syncInternals.resolveBinding = async () => current + syncInternals.projectConfigPath = async () => "/tmp/one/.altimate-code/altimate-code.json" + syncInternals.projectEntry = async () => { + seen.push("projectEntry") + return null + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + // Resolving twice lets the snapshot come from one file while the write goes + // to another, after which the undo restores the first file's entry into the + // second — over whatever the user had there. + expect(h.restorePaths, "the undo used a path other than the one the write used").toEqual([ + "/tmp/one/.altimate-code/altimate-code.json", + ]) + }) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 0315992579..299d287f64 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1354,3 +1354,28 @@ it.instance( { config: { mcp: {} } }, ) // altimate_change end + +// altimate_change start — a replacement that never came up leaves no record +it.instance( + "a failed replacement clears the record of the client it closed", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "replaced" + yield* mcp.add("replaced", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("replaced"))).toEqual(["echo", "one"]) + + // `add` over a live client closes the old one, then creates the new one. + // If that creation fails, nothing is running under the key — and the + // record must not go on describing the process that was just closed. + connectShouldFail = true + connectError = "replacement refused to start" + yield* mcp.add("replaced", { type: "local", command: ["echo", "two"] }) + connectShouldFail = false + + expect(yield* mcp.spawned("replaced"), "a closed client is still claimed to be running").toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end From 4c7ef580ee8428600a4fb0e47042f84c4b89ba88 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 13:29:04 +0800 Subject: [PATCH 55/67] refactor(workspace): say what the code does, not what review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup of what the diff SAYS. No behaviour change; 464 tests pass and the ten-mutant catalogue still kills all ten. Comments state the invariant they protect, in the present tense. Roughly forty narrated their own history instead — which round found them, what an earlier version did, how many revisions a claim survived. A comment is read by someone deciding whether they may change the line below it, and "this used to be wrong" does not tell them what must stay true. The history is in the pull request, where it belongs. Tests are named for the property they assert. Forty-four carried item codes from the review thread and thirty-two carried ON-LIFT markers explaining how they had been adapted; the reason for an adaptation belongs in a commit message, not in the file forever. The ten test files copied from reviewers are folded into files named for the question they answer — mutation-guards, undo-and-teardown, config-on-disk, unbound-and-silence, seam-contract — with each former file's cases and harness kept in their own describe. Three of them still opened with the reviewer's own "NOT for commit" header, which is fair evidence they were copies rather than adaptations. Every case survives; nothing was deduped, because the mutant catalogue is what proves coverage and dropping cases without re-running it would be guessing. Folding surfaced one hollow test of my own. "The snapshot, the write and the undo all use one resolved path" could not fail: `projectEntry`'s seam never received the path argument, so dropping it was invisible — the same defect the restore's seam had. The seam takes it now and the mutant dies. Also: the version floor loses its shipping-sequencing note, since that release is out, and a test title loses an issue key that should not be in a public repo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-config.ts | 18 +- .../src/altimate/workspace/engine-probes.ts | 4 +- .../src/altimate/workspace/engine-seams.ts | 2 +- .../src/altimate/workspace/engine-sync.ts | 87 +- .../src/altimate/workspace/engine-types.ts | 7 +- .../altimate/workspace/config-on-disk.test.ts | 387 +++++++++ .../workspace/engine-config-freshness.test.ts | 2 +- .../workspace/engine-sync-gate-l1.test.ts | 346 -------- .../altimate/workspace/engine-sync.test.ts | 93 +- .../altimate/workspace/gate-l1-r4.test.ts | 67 -- .../altimate/workspace/gate-l2-repro.test.ts | 47 - .../altimate/workspace/gate-l2-repro2.test.ts | 155 ---- .../altimate/workspace/gate-l3-r2.test.ts | 321 ------- .../workspace/gate-l3-r4-final.test.ts | 415 --------- .../altimate/workspace/gate-l4-attack.test.ts | 258 ------ .../altimate/workspace/gate-l4-r3.test.ts | 154 ---- .../altimate/workspace/gate-r3-ah.test.ts | 37 - .../altimate/workspace/l3-snapshot.test.ts | 230 ----- .../altimate/workspace/launch-resolve.test.ts | 2 +- .../workspace/mutation-guards.test.ts | 767 ++++++++++++++++ ...contract.test.ts => seam-contract.test.ts} | 24 +- .../workspace/unbound-and-silence.test.ts | 38 + .../workspace/undo-and-teardown.test.ts | 822 ++++++++++++++++++ 23 files changed, 2131 insertions(+), 2152 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/config-on-disk.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts delete mode 100644 packages/opencode/test/altimate/workspace/l3-snapshot.test.ts create mode 100644 packages/opencode/test/altimate/workspace/mutation-guards.test.ts rename packages/opencode/test/altimate/workspace/{l5-seam-contract.test.ts => seam-contract.test.ts} (96%) create mode 100644 packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts create mode 100644 packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 84f16a4678..57d8aa424a 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -1,8 +1,8 @@ // altimate_change - new file // // The module's only path to configuration. Every read refreshes first, because -// this file has been bitten three times by a cached read after someone else's -// write, and the writers cannot be enumerated. +// a cached read after someone else's write is wrong in every case here, and the +// writers cannot be enumerated. import { Config } from "@/config/config" import { addMcpToConfig, readMcpEntryFromDisk, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" import { DATAMATE_KEY } from "@/altimate/datamate-transport" @@ -62,11 +62,10 @@ export async function persist(name: string, cfg: LocalMcpConfig, configPath?: st /** The module's ONLY path to config, and it is always fresh. * - * `Config.get()` is cached per instance, and this module has now been bitten - * three times by reading it after someone else wrote: our own `addMcpToConfig`, - * `MCP.disconnect` writing `enabled: false`, and an IDE rewriting the entry — - * which never goes through `Config` at all. Two of those defeated a fix from an - * earlier round. + * `Config.get()` is cached per instance, and three different writers land + * behind it: our own `addMcpToConfig`, `MCP.disconnect` writing + * `enabled: false`, and an IDE rewriting the entry — which never goes through + * `Config` at all. * * Enumerating the writers is therefore not possible, so freshness is structural * at the point of READ rather than remembered at each write site. The cost is @@ -88,7 +87,10 @@ export async function freshConfig(): Promise<{ mcp?: Record { - if (syncInternals.projectEntry) return syncInternals.projectEntry() + // The seam takes the path too, so a test can assert the snapshot is read from + // the file the write will use. A seam that never receives the argument makes + // dropping it invisible. + if (syncInternals.projectEntry) return syncInternals.projectEntry(configPath) // THROWS rather than returning null on a read error, because the two answers // mean opposite things to the caller: `null` says "the project file has no // entry of its own", and a restore acts on that by REMOVING ours. Conflating diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index e06e628669..fe52be5e2b 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -117,8 +117,8 @@ export async function declared(datamateId: string): Promise { * `MCP.add` stores the client but publishes nothing, so nothing downstream could * even observe a late attach. This restores that signal. * - * What it does NOT do, stated plainly because this module claimed otherwise for - * several revisions: it does not give tools to the invocation already running. + * What it does NOT do, stated plainly because the name suggests otherwise: it + * does not give tools to the invocation already running. * That turn's tool set was passed to the model before the attach finished and * cannot be rebuilt mid-call — the session's subscriber only logs, and the next * `resolveTools` is what picks the tools up. So exceeding the bounded wait costs diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 6b44e9eca3..63a713f696 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -30,7 +30,7 @@ export const syncInternals: { previous: ExistingEntry | null, configPath?: string, ) => Promise - projectEntry?: () => Promise + projectEntry?: (configPath?: string) => Promise /** The configured (merged) MCP entry under `name`, or null if none. */ existingEntry?: (name: string) => Promise freshConfig?: () => Promise<{ mcp?: Record }> diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 96807f0a31..effb201877 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -124,11 +124,10 @@ export { trackedChainsForTests } from "./engine-chain" * * The order below is the contract, and it is the part of this module with the * worst history: intent outranks connectivity, connectivity outranks - * attribution, attribution outranks version. Three separate review rounds each - * found one of those checks sitting on the wrong side of another, and each time - * the defect was reachable only because an await separated them — a config read, - * a status call, a version probe. A function that cannot await cannot reorder - * itself, so those defects stop being possible rather than being fixed again. + * attribution, attribution outranks version. Each of those checks is defeated + * by sitting on the wrong side of another, and an await between them — a config + * read, a status call, a version probe — is what lets that happen. A function + * that cannot await cannot reorder itself. * * `retried` is why "one retry, never two" is a property here rather than a * branch someone has to remember not to re-enter. */ @@ -499,11 +498,9 @@ async function run(sessionID: string): Promise { try { entryNow = await existingEntry(DATAMATE_KEY) } catch (err) { - // Fails CLOSED. The previous version caught this to `null`, and `null` - // does not look disabled — so a config read that merely FAILED was read as - // permission to write, and the guard was defeated by the read breaking - // rather than by the timing window it was built for. If intent cannot be - // confirmed, nothing is written. + // Fails CLOSED. `null` from this read means "there is no entry", which + // reads as permission to write — so a read that merely FAILED must not + // produce it. If intent cannot be confirmed, nothing is written. log.warn("could not confirm intent before mutating; abandoning the attach", { workspaceId, err: String(err), @@ -597,11 +594,10 @@ async function run(sessionID: string): Promise { } /** Abandon an install without trace. * - * Both halves, together, because they were fixed one round apart: a supersede - * that undid only the runtime left our pin on disk, and MCP bootstrap starts - * every enabled entry — so a restart before the next attach would start the - * workspace this project had just walked away from. Naming them as one - * operation is what stops the next caller from remembering only one. + * Both halves, together. A supersede that undoes only the runtime leaves our + * pin on disk, and MCP bootstrap starts every enabled entry — so a restart + * before the next attach starts the workspace this project walked away from. + * Naming them as one operation is what stops a caller remembering only one. * * `projectBefore` is the PROJECT file's own entry, not the merged view. * Restoring the merged value writes a copy of a global entry into the project, @@ -671,10 +667,9 @@ async function run(sessionID: string): Promise { // waits on a person would hold a rejected client connected until they // clicked. Stop serving first, explain second. if (detach) await detachRejected(detach, bindingDependent) - // Revalidate before answering — round 13's rule, which covered two of seven - // answers because only `reused` and `attached` applied it. A refusal is an - // answer too: a re-link during the config read produced `engine-missing` for - // the workspace the project had just left, and a toast naming it. + // Revalidate before answering. A refusal is an answer: without this, a + // re-link during the config read reports `engine-missing` for the workspace + // the project has just left, and toasts a message naming it. if (!(await stillCurrent())) { log.info("binding changed before this refusal could be reported; not answering for the old workspace", { workspaceId, @@ -693,10 +688,9 @@ async function run(sessionID: string): Promise { // over theirs. Refreshing first is what makes the status gate trustworthy. // Intent, then connectivity, then attribution, then version. // - // That order is what this flow kept getting wrong: three separate review - // rounds each moved one of these checks past another, and every one of those - // mistakes was possible only because the checks were separated by an await. - // `planForEntry` cannot await, so none of them is expressible against it. + // Each check is defeated by sitting on the wrong side of another, and an + // await between them is what lets that happen. `planForEntry` cannot await, + // so no such reordering is expressible against it. // // The entry is read BEFORE the status it is judged against. `existingEntry` // refreshes the config cache that `MCP.status()` then reads, so an entry an @@ -765,9 +759,9 @@ async function run(sessionID: string): Promise { // have moved in both halves while we were starting a process. // // A revive is an install, so it owns its undo like one. A throw in the - // re-inspection used to propagate straight to the catch-all with the client - // WE had just started still registered and serving — one external failure, - // not two, and the same advice-versus-registration split as everywhere else. + // re-inspection must not reach the catch-all with the client we just + // started still registered: the outcome is advice, the registration is what + // the model sees. try { inspection = await inspectEntry() } catch (err) { @@ -971,9 +965,9 @@ async function run(sessionID: string): Promise { }) // Binding-INDEPENDENT, exactly like its irreplaceable sibling: an engine // below the floor serves nobody correctly, whatever the project is bound to - // now. Only this branch kept the default, so a re-link during the version - // probes skipped the teardown and left a too-old client connected and - // serving while the outcome said `superseded` — silently. + // now. Gating this on the binding would let a re-link during the version + // probes leave a too-old client connected and serving under a silent + // `superseded`. await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }, false) } @@ -1024,11 +1018,11 @@ async function run(sessionID: string): Promise { // mutations it guards. // Everything readable is read HERE, above the guard. `persist` otherwise // probes up to nine candidate config paths on disk between the check and the - // write it protects — round 19's defect one call deeper than round 19 looked. + // write it protects, which is a window a re-link can land in. // If we cannot record what to put back, we do not write. An unreadable - // project config previously read as "no entry here", which a later restore - // acts on by REMOVING — so a transient read failure could delete the user's - // own entry as the undo of an attach that was meant to leave it alone. + // config read that fails must not read as "no entry here": a later restore + // acts on that by REMOVING, so it would delete the user's own entry as the + // undo of an attach meant to leave it alone. // The path FIRST, and then the snapshot read from that exact path. Resolving // twice means the snapshot can come from one file while the write goes to // another — an IDE creating or removing a higher-priority config between the @@ -1046,9 +1040,9 @@ async function run(sessionID: string): Promise { return await refuseUnreadable(`config path could not be resolved: ${String(err)}`) } // If we cannot record what to put back, we do not write. An unreadable - // project config previously read as "no entry here", which a later restore - // acts on by REMOVING — so a transient read failure could delete the user's - // own entry as the undo of an attach that was meant to leave it alone. + // config read that fails must not read as "no entry here": a later restore + // acts on that by REMOVING, so it would delete the user's own entry as the + // undo of an attach meant to leave it alone. let projectBefore: ExistingEntry | null try { projectBefore = await projectEntry(configPath) @@ -1099,13 +1093,12 @@ async function run(sessionID: string): Promise { let undone = false /** Give back both halves, once, before anything else happens. * - * In-region refusals used to announce and let the `finally` tear down - * afterwards, which inverts the rule `refuse` states for every other exit: - * stop serving first, explain second. It is harmless while the announcement - * is a toast and a failed client exports nothing — but the announcement is a - * substitution point, and a body that waits on a person would leave a failed - * engine's registration and its pin outliving the dialog, with a restart - * inside it bootstrapping the entry we had already decided against. + * In-region refusals undo before they announce, which is the rule `refuse` + * states for every other exit: stop serving first, explain second. The + * announcement is a substitution point, and a body that waits on a person + * would otherwise leave a failed engine's registration and its pin outliving + * the dialog, with a restart inside it bootstrapping the entry we had already + * decided against. * * Idempotent, so the `finally` stays as a backstop for exits nobody wrote. */ const undoNow = async (): Promise => { @@ -1177,9 +1170,9 @@ async function run(sessionID: string): Promise { const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] const available = present.size // ONE guard, placed after every await that follows the install — the - // handshake AND the tool listing. Both are windows in which a re-link can - // land, and an earlier version guarded only the first, so a flip during the - // tool read left the previous workspace installed and reported as attached. + // handshake AND the tool listing. Both are windows a re-link can land in; + // guarding only the first leaves a flip during the tool read with the + // previous workspace installed and reported as attached. // // Late rather than early on purpose: the check is only meaningful at the // last moment before we announce and answer, because everything before that @@ -1200,7 +1193,7 @@ async function run(sessionID: string): Promise { // Ours, and staying. Answer BEFORE announcing: `announceToolsChanged` and // the toast are two more awaits, and the outcome asserts which workspace is - // served — round 13's rule, which the announces quietly put back at risk. + // served — so it is fixed while that assertion is still true. committed = true // The problem the user was last told about is gone. If it returns, they // should hear about it rather than have it deduplicated against a verdict diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 9ccf18349c..a2b49a6fd8 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -10,10 +10,7 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport" * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings * change cannot swap the workspace out from under a running engine. Everything * below it can drift, which is precisely what the attribution check in rule 1 - * exists to exclude — so the floor and that check are one mechanism, not two. - * - * SEQUENCING: this must not ship before `@altimateai/datamate` 0.7.0 is on npm, - * or every bound user gets `engine-too-old` for a version they cannot install. */ + * exists to exclude — so the floor and that check are one mechanism, not two. */ export const MIN_ENGINE_VERSION = "0.7.0" export const INSTALL_HINT = "npm i -g @altimateai/datamate" export const ENGINE_BINARY = "datamate" @@ -217,7 +214,7 @@ export function clearsFloor(version: string | null): boolean { * fails to compile until every table names it, and a removed one fails too. That * holds regardless of tsconfig strictness, which a `switch` with no default does * not. The safe answer is `false` in both tables, so the compiler asks the - * question and the reviewer answers it deliberately. */ + * question and the answer is chosen deliberately. */ export const SERVING: Record = { attached: true, reused: true, diff --git a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts new file mode 100644 index 0000000000..1e04601001 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts @@ -0,0 +1,387 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import { Config } from "../../../src/config/config" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" +import { persistRestore } from "../../../src/altimate/workspace/engine-config" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("the write checks the text it is about to modify", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry + bindingCalls: number + } + + function install( + statuses: H["statusQueue"], + entry: () => ExistingEntry | null, + opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}, + ): H { + const h: H = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + statusQueue: statuses, + reads: [], + spawnedNow: opts.spawned, + bindingCalls: 0, + } + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + describe("the guard's read and the write, against a real file", () => { + // No persist seam: the production `persist` → `addMcpToConfig` runs against a + // temp file. Only `Config.invalidate` is spied to a no-op (no instance here). + let dir: string + let file: string + let invalidateSpy: ReturnType + const unpinned: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + + beforeEach(async () => { + dir = mkdtempSync(path.join(tmpdir(), "l3r2-")) + file = path.join(dir, "altimate-code.json") + await addMcpToConfig("datamate", unpinned as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => invalidateSpy.mockRestore()) + + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function realInstall(landDisableAtIntentReads: number) { + let landed = false + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { + realPersist: true, + }) + syncInternals.projectConfigPath = async () => file + // The guard now reads the binding FIRST and intent LAST, + // so "after the guard's intent read and before the write" is no longer a + // window a later binding read can land in — the intent read + // IS the last thing before persist. The disable therefore lands at the end + // of that read, which is the narrowest and only remaining gap, and exactly + // the one persist's own re-read of the node it replaces exists to close. + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (!landed && h.reads.length === landDisableAtIntentReads) { + landed = true + const now = (await diskEntry())! + await addMcpToConfig("datamate", { ...now, enabled: false } as never, file) + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + return h + } + + test("disable lands between the guard's intent read and persist's write → written over, memo stands", async () => { + // reads: inspect#1 (1), worldUnchanged intent (2) → land during the binding read that follows. + const h = realInstall(2) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 outcome:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // No guard the caller can hold covers the gap between confirming intent and + // the write itself, so `persist` re-reads the node it is about to replace + // and refuses when that node says disabled. The write never happens, and + // because it never happens the post-install check no longer reads a file we + // wrote and conclude there is nothing to undo. + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled, "the user's disable was written over").toBe(false) + expect(after?.command, "disk still holds the USER's entry").toEqual(["datamate", "start-stdio"]) + expect(h.added, "installed over a disable").toHaveLength(0) + // Next turn re-decides from disk and reaches the same answer. + const second = await ensure("s1") + expect(second.kind).toBe("entry-disabled") + expect(readFileSync(file, "utf8")).toContain('"enabled": false') + }) + + test("control: the same disable landing BEFORE the guard's intent read is caught → superseded, disk keeps it", async () => { + // reads: inspect#1 (1) → land during detachRejected's binding read (before worldUnchanged reads intent). + const h = realInstall(1) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 control:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // The guard knows + // WHICH half of the world moved, and "you switched this off" is a more + // useful answer than "something changed, try again". + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled).toBe(false) + expect(after?.command).toEqual(["datamate", "start-stdio"]) + expect(h.added).toHaveLength(0) + }) + }) + + describe("a disable landing before the revive is honoured", () => { + test("spawns then tears down; writes nothing", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + // The disable has to land after inspection #1 and + // before the revive guard reads intent, and the guard's read order moved + // under this test — binding-first, then back to intent-first — so keying the + // trigger to a binding read no longer places it in the intended window. It + // lands at the end of inspection #1 instead, which is that window's opening + // edge and is stable against the guard's internal ordering. + const realEntry = syncInternals.existingEntry! + syncInternals.existingEntry = async (name: string) => { + const e = await realEntry(name) + if (h.reads.length === 1) enabled = false + return e + } + const outcome = await ensure("s1") + // the revive guard checks the whole world now, so the + // entry is never started. Start-then-tear-down was the shape this branch + // already judged worse than never-started. + expect(outcome.kind).toBe("entry-disabled") + expect(h.added, "revived the entry the user had just disabled").toHaveLength(0) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(0) + expect(h.connects).toHaveLength(0) + }) + }) + + describe("an IDE rewrite between the write and the registration", () => { + test("this turn: attached with disk unpinned; next turn: our own engine is replaced", async () => { + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: true } + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + // IDE sync lands after our persist, before our add + if (h.persisted.length === 1 && h.added.length === 0) onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + return prevAdd(n, c) + } + const first = await ensure("s1") + expect(first.kind).toBe("attached") + expect((onDisk as unknown as ExistingEntry)?.command).toEqual(["datamate", "start-stdio"]) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + const second = await ensure("s1") + console.log("R3 second:", JSON.stringify(second), "removes:", h.removes, "persisted:", h.persisted.length) + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) // tore down OUR correctly pinned engine because the file says unpinned + expect(h.persisted).toHaveLength(2) + }) + }) + + describe("the spawn record when it is absent, stale, or from another process", () => { + test("(i) bootstrap failed (no record), config pinned to us → revived via add, never connect", async () => { + const h = install( + [{ datamate: { status: "failed", error: "spawn ENOENT" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + ) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.connects).toHaveLength(0) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("(ii) dead child, record still says pinned 5 (onclose does not clear it), file re-pinned to 42 → replaced, not revived", async () => { + const h = install( + [{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(1) + }) + + test("(iii) cross-process: B bootstrapped pinned 5, A re-pinned the shared file to 7, B now bound to 7 → B replaces its own client", async () => { + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added[0]!.cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "7"]) + }) + + test("(iv) record present but the file entry was removed by another process → plan is spawn; runtime ignored", async () => { + const h = install([{}, { datamate: { status: "connected" } }], () => null, { + spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + }) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("attached") + expect((outcome as { replaced?: string }).replaced).toBeUndefined() // the 5-engine's replacement is unreported + expect(h.removes).toHaveLength(0) // storeClient closes the previous client inside MCP; this module never says so + }) + + test("(v) memo path: record diverges from file after attach (file re-pinned to 7 under a 42 binding) → memo invalid, re-decided", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }, + ) + const first = await ensure("s1") + expect(first.kind).toBe("reused") + onDisk = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true } + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) + }) + }) + + describe("edits landing between the two reads of one inspection", () => { + test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + }), { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("reused") + expect(h.persisted).toEqual([]) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + }) +}) + +describe("the undo writes only what it can justify", () => { + const A = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/a" } as any + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as any[]) delete (syncInternals as any)[k] }) + function base(h: any) { + syncInternals.resolveBinding = async () => A + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: [], extensionKeys: [] }) + syncInternals.persist = async (n, c) => { h.persisted.push(c); return "written" as const } + syncInternals.projectConfigPath = async () => "/tmp/x/altimate-code.json" + syncInternals.existingEntry = async () => h.entry + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, p) => { h.restores.push(p ?? null); return "restored" as const } + const q = [{}, { datamate: { status: "connected" } }] + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!) as any, + add: async () => { h.added += 1 }, remove: async () => { h.removes += 1 }, + spawned: async () => undefined, tools: async () => ({}), + } + } + describe("an undo whose read fails writes nothing", () => { + test("projectEntry throws at undo time: no restore write, one 'left behind' toast", async () => { + const h = { persisted: [] as any[], restores: [] as any[], toasts: [] as any[], added: 0, removes: 0, entry: null as any } + base(h) + let reads = 0 + syncInternals.projectEntry = async () => { reads += 1; if (reads >= 2) throw new Error("EIO"); return null } + const prevTools = syncInternals.mcp!.tools + syncInternals.mcp!.tools = async () => { h.entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }; return prevTools() } + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect(h.restores, "the undo wrote blind after its re-read failed").toHaveLength(0) + expect(h.toasts.map((t: any) => t.title).some((t: string) => t.includes("left behind")), JSON.stringify(h.toasts.map((t: any) => t.title))).toBe(true) + }) + }) + describe("the restore honours a disable it finds on disk", () => { + test("previous non-null: a disabled node is not overwritten", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] } as any, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp.datamate.enabled, "overwrote a disabled node").toBe(false) + expect(after.mcp.datamate.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + test("previous null (delete case): a disabled node is kept, not deleted", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", null, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate?.enabled, "deleted the node the user disabled").toBe(false) + }) + test("previous null, node enabled: removed as before", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } } }, null, 2)) + await persistRestore("datamate", null, file) + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate).toBeUndefined() + }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts index 5961ec13f3..551f6bd4fa 100644 --- a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -84,7 +84,7 @@ describe("INVARIANT — a config read observes writes made behind it", () => { }) }) -describe("INVARIANT #13 at the reader — a failed read propagates, never becomes null", () => { +describe("INVARIANT — a failed read propagates, never becomes null", () => { test("a config read that throws does not arrive at the caller as 'there is no entry'", async () => { // The layer that matters. A guard above this one was written to fail closed // on a throwing intent read — and could never fire, because this reader diff --git a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts b/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts deleted file mode 100644 index bc5682a27e..0000000000 --- a/packages/opencode/test/altimate/workspace/engine-sync-gate-l1.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -// Gate lens 1 — awaits between a binding guard and the mutation it protects. -// Disposable; lives only in the reviewer's checkout. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" - -const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE - -const A: CachedBinding = { - datamateId: 42, - datamateName: "analytics", - repoRemote: "git@github.com:acme/analytics.git", - projectPath: "/tmp/analytics", -} as CachedBinding -const B: CachedBinding = { ...A, datamateId: 99, datamateName: "other" } as CachedBinding - -type Harness = { - added: Array<{ name: string; cfg: LocalMcpConfig }> - persisted: Array<{ name: string; cfg: LocalMcpConfig }> - connects: string[] - removes: string[] - toasts: Array<{ title: string; message: string; variant: string }> - restores: unknown[] - statusQueue: Array> - tools: Record - /** Every awaited seam, in call order, with the binding it observed. */ - trace: string[] - current: CachedBinding | null -} - -function install(opts: { - which?: string | null - version?: string | null | ((bin: string) => string | null) - statuses?: Harness["statusQueue"] - tools?: Record - existing?: ExistingEntry | null -}): Harness { - const h: Harness = { - added: [], - persisted: [], - connects: [], - removes: [], - toasts: [], - restores: [], - statusQueue: opts.statuses ?? [{}], - tools: opts.tools ?? {}, - trace: [], - current: A, - } - const seam = (name: string) => h.trace.push(name) - syncInternals.resolveBinding = async () => (seam("resolveBinding"), h.current) - syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) - syncInternals.versionOf = async (bin) => { - seam("versionOf") - if (typeof opts.version === "function") return opts.version(bin) - return opts.version === undefined ? "0.7.0" : opts.version - } - syncInternals.declared = async () => (seam("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.persist = async (name, cfg) => { - seam("persist") - h.persisted.push({ name, cfg }) - } - syncInternals.projectEntry = async () => (seam("projectEntry"), null) - syncInternals.existingEntry = async () => { - seam("existingEntry") - if (opts.existing !== undefined) return opts.existing - const last = h.persisted[h.persisted.length - 1] - return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null - } - syncInternals.notify = async (toast) => { - seam("notify") - h.toasts.push(toast) - } - syncInternals.toolsChanged = async () => { - seam("toolsChanged") - } - syncInternals.persistRestore = async (_name, previous) => { - seam("persistRestore") - h.restores.push(previous ?? null) - } - // The project file has no entry of its own unless a test says otherwise. - // Required since the project reader stopped swallowing its own errors. - if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null - if (!syncInternals.projectConfigPath) - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - syncInternals.mcp = { - status: async () => (seam("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), - add: async (name, cfg) => { - seam("add") - h.added.push({ name, cfg }) - }, - remove: async (name) => { - seam("remove") - h.removes.push(name) - }, - tools: async () => (seam("tools"), h.tools), - } - return h -} - -beforeEach(() => { - process.env.ALTIMATE_WORKSPACE = "1" - resetForTests() -}) - -afterEach(() => { - for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] - if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE - else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG -}) - -// --------------------------------------------------------------------------- -// T1 — the property the author names, tested as a property: the seam awaited -// IMMEDIATELY before every mutation must be the binding read. Catches any -// awaited seam inserted between the guard and persist/add/remove/connect, -// which the existing first-call-flip tests cannot (they flip before the guard). -// --------------------------------------------------------------------------- -describe("T1 — the last awaited seam before every mutation is the binding read", () => { - const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) - - /** Which teardowns in a scenario are binding-DEPENDENT. - * - * The split is the point: a teardown that undoes what this attach created, or - * that stops a disabled or below-floor engine, is right whatever the project - * is bound to now — requiring a binding read before those would assert the - * opposite of what they are for. Only acting on a pre-existing entry we did - * not create depends on the binding. Scenarios declare which kind they - * exercise, because the trace cannot tell them apart. */ - function violations(trace: string[], removesAreBindingDependent = true): string[] { - const out: string[] = [] - for (let i = 0; i < trace.length; i++) { - if (!MUTATIONS.has(trace[i])) continue - // Walk back to the previous non-mutation seam. - let j = i - 1 - while (j >= 0 && MUTATIONS.has(trace[j])) j-- - const before = trace[j] - const beforeThat = trace[j - 1] - // persist→add is the one sanctioned adjacency (persist has no seam of its own - // to re-read after); everything else must sit directly on the world check. - if (trace[i] === "add" && trace[i - 1] === "persist") continue - // ADAPTED ON LIFT: the world check is now TWO reads in a fixed order — - // binding, then intent — because a guard that confirms only the binding is - // a guard on half the world. Intent goes last so the only thing between - // confirming it and the write is the write's own read of the node it - // replaces, which checks again where nothing can intervene. - // A WRITE needs the whole world (intent forbids creating anything); a - // TEARDOWN needs only the binding, since intent neither authorises nor - // forbids stopping a client. - const isWrite = trace[i] === "persist" || trace[i] === "add" - if (isWrite && before === "resolveBinding" && beforeThat === "existingEntry") continue - if (!isWrite && !removesAreBindingDependent) continue - if (!isWrite && before === "resolveBinding") continue - out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) - } - return out - } - - test("fresh spawn", async () => { - const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) - await ensure("s1") - expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) - }) - - test("replace an unpinned live entry", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, - statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }) - await ensure("s1") - expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) - }) - - // Its teardown is binding-INDEPENDENT: an engine below the floor serves - // nobody correctly whatever is bound now. - test("pinned-but-below-floor, PATH newer", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], - version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), - tools: { datamate_dbt_build_model: 1 }, - }) - await ensure("s1") - expect(violations(h.trace, false), h.trace.join(" > ")).toEqual([]) - }) - - test("retry-connect of a down command entry", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], - tools: { datamate_dbt_build_model: 1 }, - }) - await ensure("s1") - expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// T2 — retry-connect on a stale binding, then the refusal skips teardown -// because the binding is stale: the engine THIS attach brought up stays. -// --------------------------------------------------------------------------- -describe("T2 — retry-connect is an MCP mutation with no guard", () => { - test("a re-link before the retry: the engine we reconnected is left serving under the new binding", async () => { - const h = install({ - // Pinned to 42, down, and (once revived) below the floor; PATH no better. - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], - version: () => "0.6.5", - }) - // The re-link lands while the config is being read — before the retry. - syncInternals.existingEntry = async () => { - h.trace.push("existingEntry") - h.current = B - return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } - } - const outcome = await ensure("s1") - // ADAPTED ON LIFT. The original asserted the revived engine gets torn down. - // It is never started now: the retry is a guarded mutation, so a binding that - // moved before it means we abandon rather than start-then-undo. Nothing - // brought up is strictly better than something brought up and removed. - expect(h.connects, "reconnected an entry for a workspace the project had already left").toEqual([]) - expect(h.added, "started an engine for a workspace the project had already left").toHaveLength(0) - expect(outcome.kind).toBe("superseded") - }) - - test("a re-link DURING the retry's connect window: same result", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], - version: () => "0.6.5", - }) - // ADAPTED ON LIFT: the retry re-adds rather than connecting, so the window a - // re-link can land in is `add`, not `connect`. - const previousAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (name, cfg) => { - h.trace.push("add") - h.current = B // a TUI re-link inside the restart is the likely timing - return previousAdd(name, cfg) - } - const outcome = await ensure("s1") - // The engine THIS attach brought up is torn down whatever is bound now — - // undoing what we created is binding-independent by definition. - expect(h.removes, "the engine this attach brought up was left connected under binding 99").toContain("datamate") - expect(outcome.kind).toBe("superseded") - }) -}) - -// --------------------------------------------------------------------------- -// T3 — production persist() awaits ~10 fs operations (resolveConfigPath's -// exists() loop, addMcpToConfig's exists+readText) before its write and before -// MCP.add. Model ONE of them in the seam and flip inside it. -// --------------------------------------------------------------------------- -describe("T3 — awaits inside persist() sit between the final guard and the install", () => { - test("a re-link inside persist's config-path probe still spawns the old workspace's engine", async () => { - const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) - // ADAPTED ON LIFT. The config-path probe — up to nine `exists` calls — is no - // longer inside the write: it is resolved ABOVE the guard and handed in, so - // this models it where it now lives. That is the fix; flipping inside the - // resolved-path lookup must be caught by the guard, not undone after it. - syncInternals.projectConfigPath = async () => { - h.trace.push("resolveConfigPath") - await Promise.resolve() // Filesystem.exists(candidate) #1 of up to 9 - h.current = B - return "/tmp/test/.altimate-code/altimate-code.json" - } - const outcome = await ensure("s1") - // Round 19's own standard: the late guard undoing it is the failure, not the fix. - expect(h.added.filter((a) => a.cfg.command.includes("42")), "spawned workspace 42's engine after the re-link").toHaveLength(0) - expect(h.persisted, "wrote workspace 42's pin after the re-link").toHaveLength(0) - expect(outcome.kind).toBe("superseded") - }) - - test("a re-link inside the WRITE itself is undone rather than prevented — the named residual", async () => { - // Nothing can guard the inside of the write. What must hold is that the - // region gives back both halves of what it took. - const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - h.current = B - } - const outcome = await ensure("s1") - expect(outcome.kind).toBe("superseded") - expect(h.removes, "left the old workspace's engine registered").toContain("datamate") - expect(h.restores.length, "left the old workspace's pin on disk").toBeGreaterThan(0) - }) -}) - -// --------------------------------------------------------------------------- -// T4 — answered after awaits that follow the final guard (announce, notify). -// --------------------------------------------------------------------------- -describe("T4 — the attached answer is given after two awaits past the last guard", () => { - test("a re-link during announceToolsChanged is answered `attached` for the old workspace", async () => { - const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) - syncInternals.toolsChanged = async () => { - h.trace.push("toolsChanged") - h.current = B - } - const outcome = await ensure("s1") - // ADAPTED ON LIFT, and the residual is named rather than asserted away. - // The answer is now fixed BEFORE the announcements rather than after them, - // so the decision no longer straddles those awaits — but a re-link landing - // inside the toast still leaves this turn holding `attached` for 42. It - // cannot be guarded without either un-saying a toast already shown or - // announcing a success we then retract. - // - // What must hold is that it does not OUTLIVE the turn: the memo is keyed to - // the workspace it was taken for, so the next turn re-decides for 99 rather - // than riding it. - expect(outcome.kind).toBe("attached") - const second = await ensure("s1") - expect(second.kind, "rode a memo taken for the workspace the project had left").not.toBe("reused") - expect(h.added.at(-1)?.cfg.command, "did not re-attach for the new binding").toEqual([ - "datamate", - "start-stdio", - "--datamate", - "99", - ]) - }) -}) - -// --------------------------------------------------------------------------- -// T5 — the skip-teardown in detachRejected applies to binding-INDEPENDENT -// teardowns too: a disabled entry keeps serving for this turn after a re-link. -// --------------------------------------------------------------------------- -describe("T5 — a disabled entry's teardown is skipped on a stale binding", () => { - test("re-link during the status read: the disabled-but-connected client is left serving", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, - statuses: [{ datamate: { status: "connected" } }], - }) - syncInternals.mcp!.status = async () => { - h.trace.push("status") - h.current = B - return { datamate: { status: "connected" } } - } - const outcome = await ensure("s1") - // ADAPTED ON LIFT. The teardown is the property under test and it holds: a - // disabled entry is disabled for every workspace, so its teardown does not - // consult the binding. The ANSWER is now `superseded` rather than - // `entry-disabled`, because a refusal is an answer too and this one would - // otherwise describe — and toast about — a workspace the project has left. - expect(outcome.kind).toBe("superseded") - expect(h.removes, "a disabled entry is disabled for every workspace; its teardown does not depend on the binding").toContain("datamate") - }) -}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 02fc083425..24b9d444cb 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -109,10 +109,9 @@ function install(opts: { h.restorePaths.push(configPath) } // The project file has no entry of its own unless a test says otherwise. This - // used to be supplied by accident: the real reader swallowed its own errors - // and returned null, so an unstubbed harness looked like an empty project - // file. It now throws, because "there was nothing here" and "I could not - // look" mean opposite things to a restore. + // must be stated rather than left to the reader's error handling: "there was + // nothing here" and "I could not look" mean opposite things to a restore, so + // the reader throws and the harness says which case it wants. syncInternals.projectEntry = async () => null syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" syncInternals.mcp = { @@ -715,7 +714,7 @@ describe("ensure — reuse reports the declared-vs-delivered gap (rule 5)", () = describe("ensure — an unbound project does not keep a stale MANAGED entry", () => { test("a pinned entry is LEFT ALONE when the binding is gone — argv is not provenance", async () => { - // Reversed deliberately in round 5: a hand-authored entry is byte-identical + // A hand-authored entry is byte-identical // to one we wrote, so tearing it down would take the user's server offline. const h = install({ binding: null, @@ -879,14 +878,14 @@ describe("ensure — a superseded attach cannot overwrite the current one", () = }) }) -describe("ensure — round 4", () => { +describe("a deliberate disable is respected", () => { test("an explicitly disabled entry is respected, never silently re-enabled", async () => { // MCP.connect persists `enabled: true` into whichever config owns the entry, // so retrying a DISABLED entry would undo a deliberate global disable for // every other project. const h = install({ // A real user disable is `enabled: false` in the config. The runtime - // status alone is not evidence of intent — see the round-5 test below. + // status alone is not evidence of intent. existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, statuses: [{ datamate: { status: "disabled" } }], }) @@ -935,12 +934,12 @@ describe("ensure — round 4", () => { }) }) -describe("ensure — round 5", () => { +describe("what may be torn down, and what may not", () => { test("a REMOVED entry is not mistaken for a user disable — repair still works", async () => { // MCP.remove deletes s.status[name], and MCP.status() reports a configured // entry with no status as "disabled". Reading that as user intent made every // turn after a rejection teardown return entry-disabled, permanently — - // silently undoing the repairable-retry fix from the previous round. + // silently undoing the repairable retry. let onPath: string | null = null const h = install({ existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected @@ -998,10 +997,10 @@ describe("ensure — round 5", () => { }) }) -describe("ensure — round 6: a stale binding must not be installed", () => { +describe("a stale binding is never installed", () => { test("a re-link DURING an attach abandons it instead of installing the old workspace", async () => { // run() snapshots the binding, then spends seconds in status, version and - // API work before persisting. A re-link inside that window used to install + // API work before persisting. A re-link inside that window would install // the workspace the session had already left. let current: CachedBinding | null = binding // 42 const h = install({ @@ -1031,7 +1030,7 @@ describe("ensure — round 6: a stale binding must not be installed", () => { }) }) -describe("ensure — round 7", () => { +describe("an unexpected failure still reaches the user", () => { test("an unexpected attach error still tells the user", async () => { // Every explicit failure branch notifies; an unexpected throw must not be // the one path that leaves the user with neither tools nor an explanation. @@ -1047,7 +1046,7 @@ describe("ensure — round 7", () => { }) }) -describe("ensure — round 8", () => { +describe("a malformed version is refused", () => { test("a malformed core is refused, not treated as equal to the floor", () => { // parseInt("7rc") is 7, so "0.7rc.0" compared EQUAL to a 0.7.0 floor, and a // bare "1" won on major before its missing components were examined. @@ -1111,7 +1110,7 @@ describe("settledOutcome — a read-only view for other modules", () => { }) }) -describe("ensure — round 9", () => { +describe("intent and connectivity disagree in both directions", () => { test("a live disconnect is honoured even when the config cache is stale", async () => { // MCP.disconnect writes enabled:false to disk without invalidating Config, // so the cached entry still says enabled:true. Believing the cache would @@ -1149,7 +1148,7 @@ describe("ensure — round 9", () => { }) }) -describe("ensure — round 10", () => { +describe("announcing, bounding, and reading config fresh", () => { test("a successful add announces the new tools", async () => { // MCP.add stores the client but publishes nothing, so a late attach — after // the bounded wait expired, or on a repair retry — left the session with @@ -1194,9 +1193,9 @@ describe("ensure — round 10", () => { }) }) -describe("ensure — round 11", () => { +describe("a stalled catalog lookup never blocks the engine", () => { test("a stalled catalog lookup cannot block the REUSE path either", async () => { - // The bound added last round covered only the fresh-spawn path; a compatible + // A bound that covers only the fresh-spawn path leaves a compatible // pinned engine still awaited the lookup with no limit, and the generic API // request attaches no abort signal at all. const h = install({ @@ -1212,7 +1211,7 @@ describe("ensure — round 11", () => { }) }) -describe("ensure — round 12", () => { +describe("config is read before the status it is judged against", () => { test("an externally added entry is seen even when MCP status has not caught up", async () => { // MCP.status() reads the same cached config as everything else, so an entry // an IDE adds after the cache is warm is absent from status. Without a fresh @@ -1240,7 +1239,7 @@ describe("ensure — round 12", () => { }) }) -describe("ensure — round 13", () => { +describe("an answer is revalidated before it is given", () => { test("a re-link during the reuse lookup is not answered with the old workspace", async () => { // The reuse branch awaits the allowlist lookup for up to the bound. Returning // `reused` afterwards asserts the connected engine serves the CURRENT binding @@ -1262,7 +1261,7 @@ describe("ensure — round 13", () => { }) }) -describe("ensure — round 14", () => { +describe("a cached success is re-probed against the floor", () => { test("a cached success stops being trusted if the engine drops below the floor", async () => { // The pin is only trustworthy because the floor is: engines below it do not // lock the pin. An entry reconnected behind the same pin with a pre-floor @@ -1557,7 +1556,7 @@ describe("INVARIANT — a cached success is re-probed and re-attributed", () => } }) -describe("ensure — round 18", () => { +describe("a cached success is re-attributed before it is served", () => { test("a re-link DURING cached-success validation is not answered with the old workspace", async () => { // The memoised-success path does its own awaited validation outside run(), // so it never had run()'s final binding check. Status, config and version @@ -1735,10 +1734,9 @@ describe("INVARIANT — every outcome answers both consumer questions deliberate describe("INVARIANT — the entry decision is ordered by authority and cannot await", () => { // The order is the contract: intent > connectivity > attribution > version. - // Three review rounds each found one of these checks on the wrong side of - // another, and every one of those defects was reachable only because an await - // separated them. These assert the order directly, on the function that has - // no awaits to separate anything. + // Each check is defeated by sitting on the wrong side of another, and an + // await between them is what lets that happen. These assert the order + // directly, on the function that has no awaits to separate anything. const live = { status: "connected" } const ours = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } const theirs = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"], enabled: true } @@ -1815,9 +1813,8 @@ describe("INVARIANT — the config-writing repair primitive is unreachable", () // local decision — and a disable landing in its window was destroyed on disk // with nothing to repair it. The flow revives with `add`, which writes nothing. // - // This used to be a set of scenarios asserting the primitive was not CALLED. - // It is now asserted at compile time instead, which is strictly stronger: the - // seam does not carry `connect` at all, so a future call cannot be written. + // Asserted at compile time rather than by scenario: the seam does not carry + // `connect` at all, so a future call cannot be written. // The `@ts-expect-error` is the test — if someone puts the member back, it // becomes unused and the build fails. test("the seam does not expose it, so it cannot be called", () => { @@ -2197,10 +2194,10 @@ describe("INVARIANT — announcing never changes what happened", () => { describe("INVARIANT — a rejected engine is detached even when the rejection is a failure to know", () => { test("a probe that THROWS detaches and refuses, and says so once across turns", async () => { - // Letting the probe's throw propagate reached the catch-all BEFORE any - // teardown, so a persistent failure produced a toast on every turn while the - // rejected client stayed registered and serving — the outcome is advice, the - // registration is what the model sees. + // A probe throw that propagates reaches the catch-all BEFORE any teardown, + // so a persistent failure toasts every turn while the rejected client stays + // registered and serving — the outcome is advice, the registration is what + // the model sees. const h = install({ existing: { type: "local", command: ["/opt/datamate", "start-stdio", "--datamate", "42"], enabled: true }, statuses: [{ datamate: { status: "connected" } }], @@ -2279,9 +2276,8 @@ describe("INVARIANT #13 as a property — every seam, made to throw", () => { // Stated once over the whole seam list rather than as a handful of cases, // because the defect this catches is not a wrong answer but a MISSING // question: nothing else here asks what a function does when a read fails. - // Two instances survived nineteen review rounds on this branch and two more - // on a sibling, and none of ordering, completeness, staleness or adjacency - // could see any of them — they all test what happens when reads succeed. + // Ordering, completeness, staleness and adjacency all test what happens when + // reads SUCCEED, so none of them can see this class at all. // // Three things must hold for every seam: // 1. no mutation is performed on the strength of a failed read; @@ -2354,11 +2350,11 @@ describe("INVARIANT #13 as a property — every seam, made to throw", () => { describe("INVARIANT — an unbound project stays silent, whatever fails inside it", () => { test("an unreadable config in a project with no binding does not announce, on any turn", async () => { // The module is documented inert when nothing is linked, and most projects - // are not linked. Making the config reader propagate was right for the paths - // that DECIDE on it — and this diagnostic read, which produces a log line - // and nothing else, silently inherited it: the failure escaped to the - // catch-all and announced "attach failed" in a project that never wanted an - // attach. `connect-failed` is repairable, so it announced again every turn. + // are not linked. The config reader propagates for the paths that DECIDE on + // it; this read produces a log line and nothing else, so a failure here must + // not escape to the catch-all and announce "attach failed" in a project that + // never wanted an attach — `connect-failed` is repairable, so it would + // announce again every turn. const h = install({ binding: null, statuses: [{ datamate: { status: "connected" } }], @@ -2512,8 +2508,8 @@ describe("INVARIANT — the coverage the mutants demanded", () => { describe("INVARIANT — a revive is an install and owns its undo", () => { test("a throw after a successful revive removes the client we started", async () => { // One external failure, not two: the revive succeeds and the very next read - // throws. Before, that propagated to the catch-all with the client WE had - // just started still registered and serving — the outcome says failed, the + // throws. That must not reach the catch-all with the client WE just started + // still registered and serving — the outcome would say failed while the // registration says otherwise, and the registration is what the model sees. const h = install({ existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, @@ -2572,7 +2568,7 @@ describe("INVARIANT — an undo that fails is never silent, however it fails", ( }) }) -describe("INVARIANT — codex round 1: identity and paths are resolved once", () => { +describe("INVARIANT — identity and paths are resolved once", () => { test("a re-link is not silenced by the same refusal about the workspace it left", async () => { // The dedupe record is carried across a re-link, so without the workspace in // the key an identical-kind refusal about A silences B — and the user is @@ -2595,8 +2591,8 @@ describe("INVARIANT — codex round 1: identity and paths are resolved once", () const seen: Array = [] syncInternals.resolveBinding = async () => current syncInternals.projectConfigPath = async () => "/tmp/one/.altimate-code/altimate-code.json" - syncInternals.projectEntry = async () => { - seen.push("projectEntry") + syncInternals.projectEntry = async (configPath?: string) => { + seen.push(configPath) return null } const prevAdd = syncInternals.mcp!.add @@ -2608,6 +2604,13 @@ describe("INVARIANT — codex round 1: identity and paths are resolved once", () // Resolving twice lets the snapshot come from one file while the write goes // to another, after which the undo restores the first file's entry into the // second — over whatever the user had there. + // The snapshot, the write and the undo must all name the same file. + // Both reads — the snapshot before the write and the undo's own re-read — + // name the file the write will use. + expect(new Set(seen), "a project read used a path resolved separately").toEqual( + new Set(["/tmp/one/.altimate-code/altimate-code.json"]), + ) + expect(seen.length).toBeGreaterThan(0) expect(h.restorePaths, "the undo used a path other than the one the write used").toEqual([ "/tmp/one/.altimate-code/altimate-code.json", ]) diff --git a/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts b/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts deleted file mode 100644 index 4234e997ad..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l1-r4.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals } from "../../../src/altimate/workspace/engine-sync" -import { persistRestore } from "../../../src/altimate/workspace/engine-config" -import { mkdtempSync, writeFileSync, readFileSync } from "node:fs" -import { tmpdir } from "node:os" -import path from "node:path" -const A = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/a" } as any -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { for (const k of Object.keys(syncInternals) as any[]) delete (syncInternals as any)[k] }) -function base(h: any) { - syncInternals.resolveBinding = async () => A - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async () => "0.7.0" - syncInternals.declared = async () => ({ keys: [], extensionKeys: [] }) - syncInternals.persist = async (n, c) => { h.persisted.push(c); return "written" as const } - syncInternals.projectConfigPath = async () => "/tmp/x/altimate-code.json" - syncInternals.existingEntry = async () => h.entry - syncInternals.notify = async (t) => { h.toasts.push(t) } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async (_n, p) => { h.restores.push(p ?? null); return "restored" as const } - const q = [{}, { datamate: { status: "connected" } }] - syncInternals.mcp = { - status: async () => (q.length > 1 ? q.shift()! : q[0]!) as any, - add: async () => { h.added += 1 }, remove: async () => { h.removes += 1 }, - spawned: async () => undefined, tools: async () => ({}), - } -} -describe("AQ — the undo's failed re-read writes nothing", () => { - test("projectEntry throws at undo time: no restore write, one 'left behind' toast", async () => { - const h = { persisted: [] as any[], restores: [] as any[], toasts: [] as any[], added: 0, removes: 0, entry: null as any } - base(h) - let reads = 0 - syncInternals.projectEntry = async () => { reads += 1; if (reads >= 2) throw new Error("EIO"); return null } - const prevTools = syncInternals.mcp!.tools - syncInternals.mcp!.tools = async () => { h.entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }; return prevTools() } - const out = await ensure("s1") - expect(out.kind).toBe("entry-disabled") - expect(h.restores, "the undo wrote blind after its re-read failed").toHaveLength(0) - expect(h.toasts.map((t: any) => t.title).some((t: string) => t.includes("left behind")), JSON.stringify(h.toasts.map((t: any) => t.title))).toBe(true) - }) -}) -describe("AR — the restore's write honours a disable on disk (real file)", () => { - test("previous non-null: a disabled node is not overwritten", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") - writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) - const r = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] } as any, file) - expect(r).toBe("restored") - const after = JSON.parse(readFileSync(file, "utf8")) - expect(after.mcp.datamate.enabled, "overwrote a disabled node").toBe(false) - expect(after.mcp.datamate.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) - }) - test("previous null (delete case): a disabled node is kept, not deleted", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") - writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) - const r = await persistRestore("datamate", null, file) - expect(r).toBe("restored") - const after = JSON.parse(readFileSync(file, "utf8")) - expect(after.mcp?.datamate?.enabled, "deleted the node the user disabled").toBe(false) - }) - test("previous null, node enabled: removed as before", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") - writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } } }, null, 2)) - await persistRestore("datamate", null, file) - const after = JSON.parse(readFileSync(file, "utf8")) - expect(after.mcp?.datamate).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts deleted file mode 100644 index a35feafe3b..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Gate L2 repro — NOT for commit. Type-less `{ enabled: false }` disable marker. -import { afterEach, beforeEach, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, planForEntry } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" - -const binding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding - -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) - -test("planForEntry: a disable marker with no runtime status is honoured", () => { - // MCP.status() omits a config entry that has no `type` (mcp/index.ts:875-878), - // and the schema allows `{ enabled: false }` alone (core config.ts:119). - expect(planForEntry({ entry: { enabled: false }, observed: undefined }, "42", false)).toEqual({ act: "honour-disable" }) -}) - -test("ensure: a project `datamate: { enabled: false }` marker is not spawned over", async () => { - const added: unknown[] = [], persisted: unknown[] = [], toasts: unknown[] = [] - syncInternals.resolveBinding = async () => binding - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async () => "0.7.0" - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.existingEntry = async () => ({ enabled: false }) - syncInternals.projectEntry = async () => ({ enabled: false }) - syncInternals.persist = async (n, c) => { persisted.push({ n, c }) } - syncInternals.notify = async (t) => { toasts.push(t) } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async () => {} - let live = false - syncInternals.mcp = { - // The entry has no `type`, so status() never lists it — until WE add it. - status: async () => (live ? { datamate: { status: "connected" } } : {}), - add: async (n, c) => { added.push({ n, c }); live = true }, - remove: async () => {}, - tools: async () => ({ datamate_dbt_build_model: {} }), - } - // The project file has no entry of its own unless a test says otherwise. - // Required since the project reader stopped swallowing its own errors. - if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null - if (!syncInternals.projectConfigPath) - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - const outcome = await ensure("s1") - console.log("outcome:", JSON.stringify(outcome), "persisted:", JSON.stringify(persisted), "toasts:", JSON.stringify(toasts.map((t: any) => t.title))) - expect(outcome.kind).toBe("entry-disabled") - expect(added).toHaveLength(0) - expect(persisted).toHaveLength(0) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts b/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts deleted file mode 100644 index 9f08651bf9..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l2-repro2.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Gate L2 repro 2 — NOT for commit. -import { afterEach, beforeEach, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, planForEntry, installWouldHelp, whenAttached, settledOutcome } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" - -const b42 = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding -const b99 = { datamateId: 99, datamateName: "other", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding - -type H = { added: unknown[]; persisted: unknown[]; connects: string[]; removes: string[]; toasts: { title: string; message: string }[] } -function base(opts: { existing: unknown; statuses: Record[]; which?: string | null; binding?: () => CachedBinding | null }): H { - const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [] } - const q = opts.statuses - syncInternals.resolveBinding = async () => (opts.binding ? opts.binding() : b42) - syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) - syncInternals.versionOf = async () => "0.7.0" - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.existingEntry = async () => opts.existing as never - syncInternals.projectEntry = async () => null - syncInternals.persist = async (n, c) => { h.persisted.push({ n, c }) } - syncInternals.notify = async (t) => { h.toasts.push(t) } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async () => {} - syncInternals.mcp = { - status: async () => (q.length > 1 ? q.shift()! : q[0]!), - add: async (n, c) => { h.added.push({ n, c }) }, - remove: async (n) => { h.removes.push(n) }, - tools: async () => ({ datamate_dbt_build_model: {} }), - } - // The project file has no entry of its own unless a test says otherwise. - // Required since the project reader stopped swallowing its own errors. - if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null - if (!syncInternals.projectConfigPath) - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - return h -} -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) - -test("(a) the repair turn RECONNECTS the entry this flow tore down last turn, then rejects it again", async () => { - let onPath: string | null = null - const h = base({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected - statuses: [ - { datamate: { status: "connected" } }, - { datamate: { status: "disabled" } }, // synthesised by MCP.status() after OUR remove (mcp/index.ts:877) - { datamate: { status: "connected" } }, // MCP.connect brought the rejected engine back - { datamate: { status: "connected" } }, - ], - }) - syncInternals.which = () => onPath - expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 1 }) - expect(h.removes).toEqual(["datamate"]) - onPath = "/usr/local/bin/datamate" - await ensure("s1") - console.log("(a) turn2 connects:", h.connects, "removes:", h.removes, "added:", h.added.length) - expect(h.connects, "reconnected an engine judged unattributable one turn earlier").toHaveLength(0) -}) - -test("(b) an entry REMOVED from config but still known to the runtime is retried via MCP's runtime cfg", async () => { - // MCP.status() lists every key in s.config (mcp/index.ts:880-882) — runtime cfg - // set by our own earlier MCP.add and never cleared by MCP.remove (949-955). - // ADAPTED ON LIFT: the finding is fixed. An entry MCP still knows about but - // config no longer contains cannot be attributed to this workspace, so it is - // replaced rather than revived from whatever MCP happens to have retained. - expect(planForEntry({ entry: null, observed: { status: "disabled" } }, "42", false)).toMatchObject({ - act: "replace-unattributable", - pinnedTo: null, - }) - const h = base({ existing: null, statuses: [{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }] }) - const out = await ensure("s1") - console.log("(b) outcome:", JSON.stringify(out), "connects:", h.connects, "removes:", h.removes) - expect(h.connects).toEqual([]) // fails: connect("datamate") reconnects whatever s.config holds — planForEntry never saw it -}) - -test("(c) connect-failed with the engine binary gone: install would help, table says no", async () => { - const h = base({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, - statuses: [ - { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, - { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, - ], - which: null, - }) - const out = await ensure("s1") - console.log("(c) outcome:", JSON.stringify(out), "toast:", h.toasts.map((t) => t.message)) - // ADAPTED ON LIFT: the finding is fixed at its root rather than in the table. - // `connect-failed` with the binary gone was a lie — the engine did not fail to - // start, there was no engine — so the outcome now says `engine-missing` and - // the remedy predicate is right about it without needing a special case. - // `which` is consulted before answering, rather than reading ENOENT out of a - // platform-specific message. - expect(out.kind).toBe("engine-missing") - expect(installWouldHelp(out)).toBe(true) - expect(h.toasts[0]?.message, "told the user it failed to start rather than that it is missing").toContain( - "not installed", - ) -}) - -test("(d) a refusal is answered for a binding the project already left, with the rejected client left serving", async () => { - let current = b42 - const h = base({ - existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned, connected - statuses: [{ datamate: { status: "connected" } }], - which: null, - binding: () => current, - }) - // Re-link lands right after run() snapshots the binding (during the config read). - const realExisting = syncInternals.existingEntry! - syncInternals.existingEntry = async (n) => { current = b99; return realExisting(n) } - const out = await ensure("s1") - console.log("(d) outcome:", JSON.stringify(out), "removes:", h.removes, "toasts:", h.toasts.map((t) => t.message)) - expect(out.kind).not.toBe("engine-missing") // fails: answers engine-missing for ws 42 while ws 99 is bound; detach skipped, toast names "analytics" -}) - -test("(e) a re-link during memo validation: the next attach is filed under the OLD key and loses its wait", async () => { - let current: CachedBinding = b42 - let calls = 0 - const h = base({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, - statuses: [{ datamate: { status: "connected" } }], - binding: () => current, - }) - expect(await ensure("s1")).toMatchObject({ kind: "reused" }) - // Turn 2: engineStillOurs runs; the binding flips to 99 during its status read. - syncInternals.mcp!.status = async () => { calls += 1; if (calls === 1) current = b99; return { datamate: { status: "connected" } } } - syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", String(current.datamateId)], enabled: true }) as never - const t2 = ensure("s1") - const started = Date.now() - await whenAttached("s1", 2000) - const waited = Date.now() - started - const settledAtResolve = settledOutcome("s1") - const out2 = await t2 - await ensure("s1") - // GIVEN A REAL ASSERTION ON LIFT — it was a console.log, which is an - // observation, not a test: it could not fail and so could not protect - // anything. - // - // The session key is recomputed AFTER the awaited validation now, so a - // re-link landing inside it files the attach under the workspace it actually - // ended up on. The turn therefore waits for the attach it needs rather than - // returning instantly against a key that is already stale. - // `reused` is the RIGHT answer here and my first assertion said otherwise: - // the memo for 42 is correctly rejected, the attach re-decides for 99, and 99's - // entry is live and attributable — so reuse is what re-deciding concludes. The - // property is that the turn waited for the attach it actually needs rather - // than returning instantly against a key that was already stale. - // Not elapsed time — that assertion was flaky by construction, since a fast - // path measures 0ms at `Date.now()` resolution and the suite duly failed on - // it. The property is that the wait was actually honoured: the attach has - // SETTLED by the time `whenAttached` returns, which is what "the turn waits - // for the attach it needs" means and what dropping the wait would break. - void waited - expect(settledAtResolve, "resolved the turn before the attach it needs had settled").toBeDefined() - expect(out2.kind).toBe("reused") -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts deleted file mode 100644 index 458b05938e..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l3-r2.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -// L3 round-2 experiments against 16b47ddb4. Not part of the suite. -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { mkdtempSync, readFileSync } from "node:fs" -import { tmpdir } from "node:os" -import path from "node:path" -import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" -import { Config } from "../../../src/config/config" -import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" - -const binding: CachedBinding = { - datamateId: 42, - datamateName: "analytics", - repoRemote: "git@github.com:acme/analytics.git", - projectPath: "/tmp/analytics", -} as CachedBinding - -type H = { - added: Array<{ name: string; cfg: LocalMcpConfig }> - persisted: Array<{ name: string; cfg: LocalMcpConfig }> - connects: string[] - removes: string[] - toasts: string[] - statusQueue: Array> - reads: Array - spawnedNow?: ExistingEntry - bindingCalls: number -} - -function install( - statuses: H["statusQueue"], - entry: () => ExistingEntry | null, - opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}, -): H { - const h: H = { - added: [], - persisted: [], - connects: [], - removes: [], - toasts: [], - statusQueue: statuses, - reads: [], - spawnedNow: opts.spawned, - bindingCalls: 0, - } - syncInternals.resolveBinding = async () => { - h.bindingCalls += 1 - return binding - } - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async () => "0.7.0" - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - if (!opts.realPersist) { - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - } - } - syncInternals.existingEntry = async () => { - const e = entry() - h.reads.push(e?.enabled) - return e - } - syncInternals.notify = async (t) => { - h.toasts.push(t.title) - } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async () => {} - syncInternals.projectEntry = async () => null - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - syncInternals.mcp = { - status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), - add: async (name, cfg) => { - h.added.push({ name, cfg }) - h.spawnedNow = cfg as ExistingEntry - }, - remove: async (name) => { - h.removes.push(name) - h.spawnedNow = undefined - }, - spawned: async () => h.spawnedNow, - tools: async () => ({ datamate_dbt_build_model: 1 }), - } - return h -} - -beforeEach(() => { - process.env.ALTIMATE_WORKSPACE = "1" - resetForTests() -}) -afterEach(() => { - for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] -}) - -describe("R1 — the world guard's intent read vs the write: REAL persist on a real file", () => { - // No persist seam: the production `persist` → `addMcpToConfig` runs against a - // temp file. Only `Config.invalidate` is spied to a no-op (no instance here). - let dir: string - let file: string - let invalidateSpy: ReturnType - const unpinned: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } - - beforeEach(async () => { - dir = mkdtempSync(path.join(tmpdir(), "l3r2-")) - file = path.join(dir, "altimate-code.json") - await addMcpToConfig("datamate", unpinned as never, file) - invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) - }) - afterEach(() => invalidateSpy.mockRestore()) - - const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined - - function realInstall(landDisableAtIntentReads: number) { - let landed = false - const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { - realPersist: true, - }) - syncInternals.projectConfigPath = async () => file - // RE-STAGED ON LIFT. The guard now reads the binding FIRST and intent LAST, - // so "after the guard's intent read and before the write" is no longer a - // window that a later binding read can be used to land in — the intent read - // IS the last thing before persist. The disable therefore lands at the end - // of that read, which is the narrowest and only remaining gap, and exactly - // the one persist's own re-read of the node it replaces exists to close. - syncInternals.existingEntry = async () => { - const e = (await diskEntry()) ?? null - h.reads.push(e?.enabled) - if (!landed && h.reads.length === landDisableAtIntentReads) { - landed = true - const now = (await diskEntry())! - await addMcpToConfig("datamate", { ...now, enabled: false } as never, file) - } - return e - } - syncInternals.projectEntry = async () => (await diskEntry()) ?? null - syncInternals.resolveBinding = async () => { - h.bindingCalls += 1 - return binding - } - return h - } - - test("disable lands between the guard's intent read and persist's write → written over, memo stands", async () => { - // reads: inspect#1 (1), worldUnchanged intent (2) → land during the binding read that follows. - const h = realInstall(2) - const first = await ensure("s1") - const after = await diskEntry() - console.log("R1 outcome:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) - // INVERTED ON LIFT — this is the finding, and it is closed at the syscall. - // No guard the caller can hold covers the gap between confirming intent and - // the write itself, so `persist` re-reads the node it is about to replace - // and refuses when that node says disabled. The write never happens, and - // because it never happens the post-install check no longer reads a file we - // wrote and conclude there is nothing to undo. - expect(first.kind).toBe("entry-disabled") - expect(after?.enabled, "the user's disable was written over").toBe(false) - expect(after?.command, "disk still holds the USER's entry").toEqual(["datamate", "start-stdio"]) - expect(h.added, "installed over a disable").toHaveLength(0) - // Next turn re-decides from disk and reaches the same answer. - const second = await ensure("s1") - expect(second.kind).toBe("entry-disabled") - expect(readFileSync(file, "utf8")).toContain('"enabled": false') - }) - - test("control: the same disable landing BEFORE the guard's intent read is caught → superseded, disk keeps it", async () => { - // reads: inspect#1 (1) → land during detachRejected's binding read (before worldUnchanged reads intent). - const h = realInstall(1) - const first = await ensure("s1") - const after = await diskEntry() - console.log("R1 control:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) - // ADAPTED ON LIFT: still caught, and now reported by name. The guard knows - // WHICH half of the world moved, and "you switched this off" is a more - // useful answer than "something changed, try again". - expect(first.kind).toBe("entry-disabled") - expect(after?.enabled).toBe(false) - expect(after?.command).toEqual(["datamate", "start-stdio"]) - expect(h.added).toHaveLength(0) - }) -}) - -describe("R2 — retry path: a disable between inspection #1 and the revive add", () => { - test("spawns then tears down; writes nothing", async () => { - let enabled = true - const h = install( - [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), - ) - // RE-STAGED ON LIFT (twice). The disable has to land after inspection #1 and - // before the revive guard reads intent, and the guard's read order moved - // under this test — binding-first, then back to intent-first — so keying the - // trigger to a binding read no longer places it in the intended window. It - // lands at the end of inspection #1 instead, which is that window's opening - // edge and is stable against the guard's internal ordering. - const realEntry = syncInternals.existingEntry! - syncInternals.existingEntry = async (name: string) => { - const e = await realEntry(name) - if (h.reads.length === 1) enabled = false - return e - } - const outcome = await ensure("s1") - // INVERTED ON LIFT: the revive guard checks the whole world now, so the - // entry is never started. Start-then-tear-down was the shape this branch - // already judged worse than never-started. - expect(outcome.kind).toBe("entry-disabled") - expect(h.added, "revived the entry the user had just disabled").toHaveLength(0) - expect(h.removes).toEqual(["datamate"]) - expect(h.persisted).toHaveLength(0) - expect(h.connects).toHaveLength(0) - }) -}) - -describe("R3 — an IDE rewrite between persist and add", () => { - test("this turn: attached with disk unpinned; next turn: our own engine is replaced", async () => { - let onDisk: ExistingEntry | null = null - const h = install([{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - onDisk = { type: "local", command: cfg.command, enabled: true } - } - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { - // IDE sync lands after our persist, before our add - if (h.persisted.length === 1 && h.added.length === 0) onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } - return prevAdd(n, c) - } - const first = await ensure("s1") - expect(first.kind).toBe("attached") - expect((onDisk as unknown as ExistingEntry)?.command).toEqual(["datamate", "start-stdio"]) - expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) - const second = await ensure("s1") - console.log("R3 second:", JSON.stringify(second), "removes:", h.removes, "persisted:", h.persisted.length) - expect(second).not.toBe(first) - expect(h.removes).toEqual(["datamate"]) // tore down OUR correctly pinned engine because the file says unpinned - expect(h.persisted).toHaveLength(2) - }) -}) - -describe("R4 — the spawned record: absent, stale, and cross-process", () => { - test("(i) bootstrap failed (no record), config pinned to us → revived via add, never connect", async () => { - const h = install( - [{ datamate: { status: "failed", error: "spawn ENOENT" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), - ) - const outcome = await ensure("s1") - expect(outcome.kind).toBe("reused") - expect(h.added).toHaveLength(1) - expect(h.connects).toHaveLength(0) - expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) - }) - - test("(ii) dead child, record still says pinned 5 (onclose does not clear it), file re-pinned to 42 → replaced, not revived", async () => { - const h = install( - [{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), - { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, - ) - const outcome = await ensure("s1") - expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) - expect(h.removes).toEqual(["datamate"]) - expect(h.persisted).toHaveLength(1) - }) - - test("(iii) cross-process: B bootstrapped pinned 5, A re-pinned the shared file to 7, B now bound to 7 → B replaces its own client", async () => { - syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding - const h = install( - [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true }), - { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, - ) - syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding - const outcome = await ensure("s1") - expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) - expect(h.removes).toEqual(["datamate"]) - expect(h.added[0]!.cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "7"]) - }) - - test("(iv) record present but the file entry was removed by another process → plan is spawn; runtime ignored", async () => { - const h = install([{}, { datamate: { status: "connected" } }], () => null, { - spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, - }) - const outcome = await ensure("s1") - expect(outcome.kind).toBe("attached") - expect((outcome as { replaced?: string }).replaced).toBeUndefined() // the 5-engine's replacement is unreported - expect(h.removes).toHaveLength(0) // storeClient closes the previous client inside MCP; this module never says so - }) - - test("(v) memo path: record diverges from file after attach (file re-pinned to 7 under a 42 binding) → memo invalid, re-decided", async () => { - let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } - const h = install( - [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], - () => onDisk, - { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }, - ) - const first = await ensure("s1") - expect(first.kind).toBe("reused") - onDisk = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true } - const second = await ensure("s1") - expect(second).not.toBe(first) - expect(h.removes).toEqual(["datamate"]) - }) -}) - -describe("R5 — (a)/(b) between the two reads inside inspectEntry, unchanged from round 1", () => { - test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { - let enabled = true - const h = install([{ datamate: { status: "connected" } }], () => ({ - type: "local", - command: ["datamate", "start-stdio", "--datamate", "42"], - enabled, - }), { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }) - const realStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { - enabled = false - return realStatus() - } - expect((await ensure("s1")).kind).toBe("reused") - expect(h.persisted).toEqual([]) - expect((await ensure("s1")).kind).toBe("entry-disabled") - expect(h.removes).toEqual(["datamate"]) - }) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts b/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts deleted file mode 100644 index bd04bbea49..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l3-r4-final.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -// L3 confirmation-pass experiments against 8c78d98eb. Not part of the suite. -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { mkdtempSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import path from "node:path" -import { ensure, resetForTests, settledOutcome, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" -import { Config } from "../../../src/config/config" -import { Filesystem } from "../../../src/util/filesystem" -import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" - -const binding: CachedBinding = { - datamateId: 42, - datamateName: "analytics", - repoRemote: "git@github.com:acme/analytics.git", - projectPath: "/tmp/analytics", -} as CachedBinding - -type H = { - added: Array<{ name: string; cfg: LocalMcpConfig }> - persisted: Array<{ name: string; cfg: LocalMcpConfig }> - removes: string[] - restores: Array - toasts: Array<{ title: string; message: string }> - statusQueue: Array> - reads: Array - spawnedNow?: ExistingEntry -} - -function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null, opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}): H { - const h: H = { added: [], persisted: [], removes: [], restores: [], toasts: [], statusQueue: statuses, reads: [], spawnedNow: opts.spawned } - syncInternals.resolveBinding = async () => binding - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async () => "0.7.0" - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - if (!opts.realPersist) { - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - } - } - syncInternals.existingEntry = async () => { - const e = entry() - h.reads.push(e?.enabled) - return e - } - syncInternals.notify = async (t) => { - h.toasts.push({ title: t.title, message: t.message }) - } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async (_n, prev) => { - h.restores.push(prev) - } - syncInternals.projectEntry = async () => null - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - syncInternals.mcp = { - status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), - add: async (name, cfg) => { - h.added.push({ name, cfg }) - h.spawnedNow = cfg as ExistingEntry - }, - remove: async (name) => { - h.removes.push(name) - h.spawnedNow = undefined - }, - spawned: async () => h.spawnedNow, - tools: async () => ({ datamate_dbt_build_model: 1 }), - } - return h -} - -beforeEach(() => { - process.env.ALTIMATE_WORKSPACE = "1" - resetForTests() -}) -afterEach(() => { - for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] -}) - -const DISABLED_FILE = JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } }, null, 2) -const PINNED42 = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } as ExistingEntry - -describe("AG — real persist: the check is on the same text the write modifies", () => { - let file: string - let invalidateSpy: ReturnType - const originalReadText = Filesystem.readText - beforeEach(async () => { - file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-")), "altimate-code.json") - await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) - invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) - }) - afterEach(() => { - invalidateSpy.mockRestore() - Filesystem.readText = originalReadText - }) - const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined - - /** After the guard's intent read, config-file readText #1 is now addMcpToConfig's - * ONLY read (persist has no separate check read any more). */ - function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") { - const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true }) - syncInternals.projectConfigPath = async () => file - let armed = false - let landed = false - let n = 0 - syncInternals.existingEntry = async () => { - const e = (await diskEntry()) ?? null - h.reads.push(e?.enabled) - if (h.reads.length === 2) { - if (where === "intent-read-end" && !landed) { - landed = true - writeFileSync(file, DISABLED_FILE) - } - armed = true - } - return e - } - syncInternals.projectEntry = async () => (await diskEntry()) ?? null - Filesystem.readText = async (p: string) => { - if (!armed || p !== file || landed) return originalReadText(p) - n += 1 - if (n !== 1) return originalReadText(p) - landed = true - if (where === "before-write-read") { - writeFileSync(file, DISABLED_FILE) - return originalReadText(p) - } - const text = await originalReadText(p) - writeFileSync(file, DISABLED_FILE) - return text - } - return { h, reads: () => n } - } - - test("W1: disable at the end of the guard's intent read → refused by the write's own read", async () => { - const { h } = stage("intent-read-end") - const out = await ensure("s1") - expect(out.kind).toBe("entry-disabled") - expect((await diskEntry())?.enabled).toBe(false) - expect(h.added).toHaveLength(0) - expect(h.toasts).toHaveLength(1) - }) - - test("W0/W2 (merged by construction): disable lands before the write's single read → refused", async () => { - const { h, reads } = stage("before-write-read") - const out = await ensure("s1") - console.log("W0/W2:", JSON.stringify(out), "disk:", JSON.stringify(await diskEntry()), "config reads after guard:", reads()) - expect(out.kind).toBe("entry-disabled") - expect((await diskEntry())?.enabled).toBe(false) - expect(h.added).toHaveLength(0) - }) - - test("W3 (named residual): disable lands between the write's read and its write → still lost", async () => { - const { h } = stage("after-write-read") - const out = await ensure("s1") - const after = await diskEntry() - console.log("W3:", JSON.stringify(out), "disk:", JSON.stringify(after)) - expect(out.kind).toBe("attached") - expect(after?.enabled).toBe(true) - expect(after?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) - expect(h.added).toHaveLength(1) - expect(await ensure("s1")).toBe(out) - }) -}) - -describe("AH/AI — freshConfig throws at each read in turn (real existingEntry, no seam)", () => { - function realReader(throwAt: (n: number) => boolean, onDisk: () => ExistingEntry | null) { - const h = install([{}, { datamate: { status: "connected" } }], () => null) - delete syncInternals.existingEntry - let n = 0 - syncInternals.freshConfig = async () => { - n += 1 - if (throwAt(n)) throw new Error(n === 1 || throwAt(1) ? "EIO" : `EIO#`) - const e = onDisk() - return { mcp: e ? { datamate: e } : {} } - } - return { h, calls: () => n } - } - - test("read #1 (inspection) throws → connect-failed, 1 toast, no mutation", async () => { - const { h } = realReader((n) => n === 1, () => null) - const out = await ensure("s1") - expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: Error: EIO" }) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) - expect(h.persisted).toHaveLength(0) - expect(h.added).toHaveLength(0) - }) - - test("read #2 (pre-install guard) throws → connect-failed, 1 toast, no mutation; same label as the inspection", async () => { - const { h } = realReader((n) => n === 2, () => null) - const out = await ensure("s1") - expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: intent could not be confirmed" }) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) - expect(h.persisted).toHaveLength(0) - expect(h.added).toHaveLength(0) - }) - - test("read #3 (post-install guard) throws → install undone, connect-failed, 1 toast", async () => { - const { h } = realReader((n) => n === 3, () => null) - const out = await ensure("s1") - expect(out).toMatchObject({ kind: "connect-failed" }) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) - expect(h.persisted).toHaveLength(1) - expect(h.added).toHaveLength(1) - expect(h.removes).toEqual(["datamate"]) - expect(h.restores).toEqual([null]) - }) - - test("undo re-read (projectEntry #2) throws → FAILS CLOSED: no restore, one left-behind toast, superseded", async () => { - let current: CachedBinding | null = binding - const h = install([{}, { datamate: { status: "connected" } }], () => null) - syncInternals.resolveBinding = async () => current - let pe = 0 - syncInternals.projectEntry = async () => { - pe += 1 - if (pe === 2) throw new Error("EIO undo re-read") - return null - } - syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) - const out = await ensure("s1") - expect(out.kind).toBe("superseded") - expect(pe).toBe(2) - expect(h.restores).toEqual([]) - expect(h.removes).toEqual(["datamate"]) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) - }) - - test("memo validation read throws (transient) → not served, re-decided → reused; no toast", async () => { - const { h } = realReader((n) => n === 4, () => (h.added.length ? PINNED42 : null)) - const first = await ensure("s1") - expect(first.kind).toBe("attached") - h.statusQueue = [{ datamate: { status: "connected" } }] - const second = await ensure("s1") - expect(second).not.toBe(first) - expect(second.kind).toBe("reused") - expect(h.toasts).toHaveLength(1) - }) - - test("PERSISTENT throw: three turns re-decide but announce ONCE (AL)", async () => { - const { h, calls } = realReader(() => true, () => null) - const a = await ensure("s1") - const b = await ensure("s1") - const c = await ensure("s1") - console.log("AH persistent:", a.kind, b.kind, c.kind, "toasts:", h.toasts.length, "freshConfig calls:", calls()) - expect([a.kind, b.kind, c.kind]).toEqual(["connect-failed", "connect-failed", "connect-failed"]) - expect(h.toasts.length).toBe(1) - expect(h.persisted).toHaveLength(0) - }) -}) - -describe("AJ — persistent probe failure in the check-version branch", () => { - test("turn 1: detach + refuse once (engine-too-old), client not left registered; later turns re-decide silently (AL)", async () => { - const h = install( - [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], - () => PINNED42, - { spawned: PINNED42 }, - ) - syncInternals.versionOf = async () => { - throw new Error("EACCES") - } - const a = await ensure("s1") - expect(a.kind).toBe("engine-too-old") - expect(h.removes).toEqual(["datamate"]) - expect(h.spawnedNow).toBeUndefined() - expect(h.toasts).toHaveLength(1) - expect(settledOutcome("s1")?.kind).toBe("engine-too-old") - - // Turn 2: the outcome is REPAIRABLE, so the memo does not hold it — run() again. - const b = await ensure("s1") - const c = await ensure("s1") - console.log("AJ:", b.kind, c.kind, "toasts:", h.toasts.length, "added:", h.added.length, "removes:", h.removes.length) - expect(b).not.toBe(a) - expect(h.toasts.length).toBe(1) - }) -}) - -describe("spawned cleared on onclose / disconnect — the next attach", () => { - test("child exit (record cleared, status failed) → revived via add → reused", async () => { - const h = install([{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], () => PINNED42) - const out = await ensure("s1") - expect(out.kind).toBe("reused") - expect(h.added).toHaveLength(1) - expect(h.spawnedNow?.command).toEqual(PINNED42.command) - expect(h.persisted).toHaveLength(0) - }) - - test("disconnect (record cleared, status disabled, config enabled:false) → entry-disabled; then /mcp enable-style re-add → reused", async () => { - let enabled = true - let status: { status: string } = { status: "connected" } - const h = install([], () => ({ ...PINNED42, enabled }), { spawned: PINNED42 }) - syncInternals.mcp!.status = async () => ({ datamate: status }) - expect((await ensure("s1")).kind).toBe("reused") - // MCP.disconnect: closeClient, delete spawned, status disabled, persist enabled:false - enabled = false - status = { status: "disabled" } - h.spawnedNow = undefined - const mid = await ensure("s1") - expect(mid.kind).toBe("entry-disabled") - expect(h.added).toHaveLength(0) - // MCP.connect (prompt.ts /mcp enable): createAndStore → spawned set, status connected, persist enabled:true - enabled = true - status = { status: "connected" } - h.spawnedNow = PINNED42 - const back = await ensure("s1") - expect(back.kind).toBe("reused") - expect(h.added).toHaveLength(0) - expect(h.persisted).toHaveLength(0) - }) -}) - -describe("AL — dedupe edges", () => { - test("same kind, changed detail (engine-too-old 0.5.9 → 0.6.0) speaks again", async () => { - let v = "0.5.9" - const h = install([{}], () => null) - syncInternals.versionOf = async () => v - expect((await ensure("s1")).kind).toBe("engine-too-old") - expect((await ensure("s1")).kind).toBe("engine-too-old") - v = "0.6.0" - expect((await ensure("s1")).kind).toBe("engine-too-old") - console.log("AL detail:", h.toasts.length, h.toasts.map((t) => t.message.slice(0, 40))) - expect(h.toasts.length).toBe(2) - }) - test("two sessions with the same verdict each hear it once", async () => { - const h = install([{}], () => null) - syncInternals.which = () => null - await ensure("a"); await ensure("a"); await ensure("b"); await ensure("b") - expect(h.toasts.length).toBe(2) - }) - test("a reuse after a refusal clears the record: refusal → reused → same refusal speaks again", async () => { - let onPath: string | null = null - let status: { status: string } = { status: "disabled" } - const h = install([], () => PINNED42, { spawned: undefined }) - syncInternals.which = () => onPath - syncInternals.mcp!.status = async () => ({ datamate: status }) - expect((await ensure("s1")).kind).toBe("engine-missing") // ours+down → retry → revive? no: which null → refuse-unreachable → engine-missing - onPath = "/usr/local/bin/datamate"; status = { status: "connected" }; h.spawnedNow = PINNED42 - expect((await ensure("s1")).kind).toBe("reused") - onPath = null; status = { status: "disabled" }; h.spawnedNow = undefined - expect((await ensure("s1")).kind).toBe("engine-missing") - expect(h.toasts.filter((t) => t.title.includes("unavailable")).length).toBe(2) - }) -}) - -describe("RT/RU — the restore's own window: a disable landing between the undo's read and the restore's write (REAL persist + REAL persistRestore)", () => { - let file: string - let invalidateSpy: ReturnType - const originalReadText = Filesystem.readText - beforeEach(() => { - file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-restore-")), "altimate-code.json") - invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) - }) - afterEach(() => { - invalidateSpy.mockRestore() - Filesystem.readText = originalReadText - }) - const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined - - function stageRestore(initial: ExistingEntry | null) { - let current: CachedBinding | null = binding - const statuses: H["statusQueue"] = initial ? [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }] : [{}, { datamate: { status: "connected" } }] - const h = install(statuses, () => null, { realPersist: true }) - delete syncInternals.persistRestore // REAL restore - syncInternals.projectConfigPath = async () => file - syncInternals.resolveBinding = async () => current - syncInternals.existingEntry = async () => { - const e = (await diskEntry()) ?? null - h.reads.push(e?.enabled) - return e - } - let pe = 0 - let armed = false - let landed = false - syncInternals.projectEntry = async () => { - pe += 1 - const e = (await diskEntry()) ?? null - if (pe === 2) armed = true // the undo's own read has just completed - return e - } - // binding moves during tools() → post-install guard → undo - syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) - Filesystem.readText = async (p: string) => { - if (armed && !landed && p === file) { - landed = true - // the user disables OUR entry after the undo read it and before the restore writes - const now = (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry - writeFileSync(file, JSON.stringify({ mcp: { datamate: { ...now, enabled: false } } }, null, 2)) - } - return originalReadText(p) - } - return { h, landed: () => landed } - } - - test("RT: previous entry existed → restore must NOT overwrite the disable with the enabled previous", async () => { - await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) - const { h, landed } = stageRestore({ type: "local", command: ["datamate", "start-stdio"], enabled: true }) - const out = await ensure("s1") - const after = await diskEntry() - console.log("RT:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) - expect(landed()).toBe(true) - expect(out.kind).toBe("superseded") - expect(after?.enabled, "the restore wrote the enabled previous entry over the user's disable").toBe(false) - }) - - test("RU: no previous entry → restore must NOT delete the node the user just disabled", async () => { - writeFileSync(file, "{}\n") - const { h, landed } = stageRestore(null) - const out = await ensure("s1") - const after = await diskEntry() - console.log("RU:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) - expect(landed()).toBe(true) - expect(out.kind).toBe("superseded") - expect(after, "the restore deleted the node the user had just disabled").toBeDefined() - expect(after?.enabled).toBe(false) - }) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts deleted file mode 100644 index 8bb469c68f..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l4-attack.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Gate L4 attack tests — each test asserts the teardown property the lens -// requires; a FAILING test here is a demonstrated gap. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" - -const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE -const binding: CachedBinding = { - datamateId: 42, - datamateName: "analytics", - repoRemote: "git@github.com:acme/analytics.git", - projectPath: "/tmp/analytics", -} as CachedBinding -const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding - -type H = { - added: Array<{ name: string; cfg: LocalMcpConfig }> - persisted: Array<{ name: string; cfg: LocalMcpConfig }> - connects: string[] - removes: string[] - toasts: Array<{ title: string; message: string; variant: string }> - restores: Array - statusQueue: Array> - tools: Record -} -function install(opts: { - which?: string | null - version?: string | null | ((bin: string) => string | null) - statuses?: H["statusQueue"] - tools?: Record - existing?: ExistingEntry | null - projectEntry?: ExistingEntry | null -}): H { - const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } - syncInternals.resolveBinding = async () => binding - syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) - syncInternals.versionOf = async (bin) => (typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.persist = async (name, cfg) => { h.persisted.push({ name, cfg }) } - syncInternals.existingEntry = async () => { - if (opts.existing !== undefined) return opts.existing - const last = h.persisted[h.persisted.length - 1] - return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null - } - syncInternals.projectEntry = async () => opts.projectEntry ?? null - syncInternals.notify = async (t) => { h.toasts.push(t) } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async (_n, prev) => { h.restores.push(prev ?? null) } - syncInternals.mcp = { - status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), - add: async (name, cfg) => { h.added.push({ name, cfg }) }, - remove: async (name) => { h.removes.push(name) }, - tools: async () => h.tools, - } - // The project file has no entry of its own unless a test says otherwise. - // Required since the project reader stopped swallowing its own errors. - if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null - if (!syncInternals.projectConfigPath) - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - return h -} -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { - for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] - if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE - else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG -}) - -describe("A/B — detachRejected is gated on stillCurrent, so a supersede skips the runtime teardown", () => { - test("A: entry DISABLED + connected, re-link lands between status() and refuse → client left serving", async () => { - let current: CachedBinding | null = binding - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, - statuses: [{ datamate: { status: "connected" } }], - }) - syncInternals.resolveBinding = async () => current - const prevStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { const s = await prevStatus(); current = other; return s } - const outcome = await ensure("s1") - // ADAPTED ON LIFT: the teardown is the property and it holds. The answer is - // `superseded` because a refusal is an answer too, and this one would have - // described a workspace the project had already left. - expect(outcome).toEqual({ kind: "superseded" }) - expect(h.removes, "disabled entry reported but its live client was NOT removed (detachRejected skipped on supersede)").toContain("datamate") - }) - test("B: pinned-to-us, below floor, nothing better on PATH, re-link lands in versionOf → too-old client left serving", async () => { - let current: CachedBinding | null = binding - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, - statuses: [{ datamate: { status: "connected" } }], - version: () => { current = other; return "0.5.0" }, - }) - syncInternals.resolveBinding = async () => current - const outcome = await ensure("s1") - // ADAPTED ON LIFT: as above — teardown holds, the answer is `superseded`. - expect(outcome).toMatchObject({ kind: "superseded" }) - expect(h.removes, "too-old engine reported but left registered (detachRejected skipped on supersede)").toContain("datamate") - }) -}) - -describe("D — connect-failed AFTER install never restores what persist() replaced", () => { - test("user's hand-authored PROJECT entry is overwritten by our pin; spawn fails; nothing puts it back", async () => { - const users: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"] } // unpinned, in project file, live - const h = install({ - existing: users, - projectEntry: users, - statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "failed", error: "exit 1" } }], - }) - const outcome = await ensure("s1") - // ADAPTED ON LIFT: the finding is fixed. The install region gives back both - // halves on every non-attached exit, so a failed spawn puts the user's own - // entry back instead of leaving our pin over it. - expect(outcome).toMatchObject({ kind: "connect-failed" }) - expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - expect(h.restores, "the failed spawn left our pin over the user's project entry").toEqual([users]) - }) -}) - -describe("F — connect-failed after install, superseded: stale pin stays on disk and wedges the new workspace", () => { - test("turn 1: install 42, re-link to 99 during add, spawn fails → refuse() without undoInstall", async () => { - let current: CachedBinding | null = binding - const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) - syncInternals.resolveBinding = async () => current - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } - const outcome = await ensure("s1") - // ADAPTED ON LIFT: the finding is fixed. A failed spawn is a non-attached - // exit, so the region gives back the pin it wrote — it does not survive to - // wedge the next turn. - // The re-link lands during the add, so the refusal revalidates and declines - // to answer for the workspace the project has left. - expect(outcome).toMatchObject({ kind: "superseded" }) - // Both halves: the pin WAS written, and it was given back. - expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - expect(h.restores.length, "the failed spawn's pin was left on disk to wedge the next turn").toBeGreaterThan(0) - }) - test("turn 2 under binding 99: the failing 42 pin is retried once and refused — 99 never spawns", async () => { - let current: CachedBinding | null = binding - const h = install({ - statuses: [ - {}, // turn 1 initial - { datamate: { status: "failed", error: "exit 1" } }, // turn 1 after add - { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial - { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry - ], - }) - syncInternals.resolveBinding = async () => current - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } - // ADAPTED ON LIFT: the finding is fixed on both counts. Turn 1's re-link - // during the add makes the refusal decline to answer for the workspace just - // left, and its pin is given back rather than left to wedge turn 2. Turn 2 - // then judges 42's pin unattributable under binding 99 and REPLACES it - // instead of retrying it, so 99 gets its engine. `connect-failed` on turn 2 - // is the fixture's own doing: its status queue reports the freshly spawned - // engine as failed too. - expect(await ensure("s1")).toMatchObject({ kind: "superseded" }) - syncInternals.mcp!.add = prevAdd - const second = await ensure("s1") - expect(second).toMatchObject({ kind: "connect-failed" }) - expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) - expect(h.added.map((a) => a.cfg.command), "workspace 99 never gets an engine: the stale failing 42 pin blocks it every turn").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) - }) -}) - -describe("E — a throw after install bypasses undoInstall entirely", () => { - test("re-link during add, then tools() throws → engine for 42 stays installed under binding 99, outcome connect-failed", async () => { - let current: CachedBinding | null = binding - const h = install({ statuses: [{}, { datamate: { status: "connected" } }] }) - syncInternals.resolveBinding = async () => current - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } - syncInternals.mcp!.tools = async () => { throw new Error("tools listing exploded") } - const outcome = await ensure("s1") - // ADAPTED ON LIFT, twice. A throw no longer unwinds past the undo — the - // region gives back both halves on any non-attached exit, including one - // nobody wrote. And because this throw lands AFTER a re-link, it is now the - // same silent `superseded` as every other refusal for a workspace the - // project has left: answering would name the wrong workspace, and toasting - // about it would be worse. - expect(outcome).toMatchObject({ kind: "superseded" }) - expect(h.toasts, "announced a failure for the workspace the project had left").toHaveLength(0) - expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - expect(h.removes, "a throw left the client registered").toContain("datamate") - expect(h.restores, "a throw left our pin on disk").toHaveLength(1) - }) -}) - -describe("C — retry-connect calls MCP.connect on a global-only entry (persists enabled:true into the owning file)", () => { - test("a down, enabled, IDE-shaped entry is retried via MCP.connect", async () => { - const h = install({ - existing: { command: "datamate", args: ["start-stdio"] }, // IDE shape, no `enabled` field, lives in global - statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], - }) - await ensure("s1") - // ADAPTED ON LIFT, then STRENGTHENED. The finding is fixed: repairing a down - // IDE-shaped entry used `MCP.connect`, which persists `enabled: true` into - // the file that owns the entry — a global write from a local decision. - // - // Asserting only "connect was not called" is now vacuous, since the seam no - // longer carries it. What earns its place is that the repair happened, with - // the right primitive and the entry we judged, and wrote nothing. - // And the scenario no longer reaches the repair at all: an IDE-shaped entry - // is UNPINNED, so attribution replaces it before connectivity is ever - // consulted. What lands is our own pinned entry, written to the project - // config — not a global write to theirs, which was the defect. - expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - }) -}) - -describe("F3 — the general wedge: a persisted pin that later fails blocks the NEW workspace forever", () => { - test("clean attach of 42; user re-links to 99; 42's engine is now down → retried once, refused; 99 never spawns", async () => { - let current: CachedBinding | null = binding - const h = install({ - statuses: [ - {}, // turn 1 initial - { datamate: { status: "connected" } }, // turn 1 after add - { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial (42's engine died) - { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry - ], - tools: { datamate_dbt_build_model: 1 }, - }) - syncInternals.resolveBinding = async () => current - expect(await ensure("s1")).toMatchObject({ kind: "attached" }) - current = other - const second = await ensure("s1") - // ADAPTED ON LIFT: the wedge is fixed. 42's pin is unattributable under - // binding 99, so it is replaced rather than retried, and 99 gets its engine. - // `connect-failed` here is the fixture's own doing — the status queue reports - // the freshly spawned engine as failed too. - expect(second).toMatchObject({ kind: "connect-failed" }) - expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) - expect(h.added.map((a) => a.cfg.command), "99 blocked behind the failing 42 pin").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) - }) -}) - -describe("G — refuse() order at 2d8bea2d0: teardown runs BEFORE announceRefusal; a throwing announce relabels the outcome", () => { - test("disabled+connected entry, notify seam throws: client IS removed (teardown first), but outcome becomes connect-failed and a 2nd toast fires", async () => { - const h = install({ - existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, - statuses: [{ datamate: { status: "connected" } }], - }) - let notifyCalls = 0 - syncInternals.notify = async (t) => { - notifyCalls += 1 - if (notifyCalls === 1) throw new Error("dialog surface exploded") - h.toasts.push(t) - } - const outcome = await ensure("s1") - expect(h.removes, "teardown did not run before the announce").toContain("datamate") - // ADAPTED ON LIFT: the finding is fixed. A throwing announce no longer - // reaches the catch-all, so the verdict stands and no second toast fires. - expect(notifyCalls, "a failed announcement was retried through a second toast site").toBe(1) - expect(outcome.kind, "a throwing announce relabels entry-disabled as connect-failed").toBe("entry-disabled") - }) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts b/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts deleted file mode 100644 index b914424ed7..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-l4-r3.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Gate L4 round-3 attack tests against 6cb70bb43. A FAILING test = demonstrated gap (or a documented observation, as labelled). -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" - -const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE -const binding: CachedBinding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/analytics" } as CachedBinding -const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding -type H = { trace: string[]; added: Array<{ name: string; cfg: LocalMcpConfig }>; persisted: Array<{ name: string; cfg: LocalMcpConfig }>; removes: string[]; toasts: Array<{ title: string; message: string; variant: string }>; restores: Array; statusQueue: Array>; tools: Record } -function install(opts: { which?: string | null; version?: string | null | ((bin: string) => string | null); statuses?: H["statusQueue"]; tools?: Record; existing?: ExistingEntry | null | (() => ExistingEntry | null); projectEntry?: ExistingEntry | null | (() => ExistingEntry | null) }): H { - const h: H = { trace: [], added: [], persisted: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } - const t = (s: string) => h.trace.push(s) - syncInternals.resolveBinding = async () => (t("resolveBinding"), binding) - syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) - syncInternals.versionOf = async (bin) => (t("versionOf"), typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) - syncInternals.declared = async () => (t("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.persist = async (name, cfg) => { t("persist"); h.persisted.push({ name, cfg }) } - syncInternals.existingEntry = async () => { t("existingEntry"); if (typeof opts.existing === "function") return opts.existing(); if (opts.existing !== undefined) return opts.existing; const last = h.persisted[h.persisted.length - 1]; return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null } - syncInternals.projectEntry = async () => { t("projectEntry"); return typeof opts.projectEntry === "function" ? opts.projectEntry() : (opts.projectEntry ?? null) } - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - syncInternals.notify = async (tt) => { t("notify"); h.toasts.push(tt) } - syncInternals.toolsChanged = async () => { t("toolsChanged") } - syncInternals.persistRestore = async (_n, prev) => { t("persistRestore"); h.restores.push(prev ?? null) } - syncInternals.mcp = { status: async () => (t("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), add: async (name, cfg) => { t("add"); h.added.push({ name, cfg }) }, remove: async (name) => { t("remove"); h.removes.push(name) }, tools: async () => (t("tools"), h.tools) } - return h -} -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k]; if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE; else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG }) -const ours: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } - -describe("AB — the undo re-reads the project entry at undo time", () => { - test("AB-1: disable lands on OUR node during the boot; re-read succeeds → the disabled node is kept (not deleted), outcome entry-disabled", async () => { - let phase = 0 - const h = install({ - statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, - existing: () => (phase === 0 ? null : { ...ours, enabled: false }), - projectEntry: () => (phase === 0 ? null : { ...ours, enabled: false }), - }) - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "entry-disabled" }) - expect(h.removes).toContain("datamate") - expect(h.restores, "the user's disable was undone").toEqual([{ ...ours, enabled: false }]) - }) - test("AB-2 (#13): same, but the undo-time re-read THROWS → falls back to the snapshot restore and DELETES the node the user disabled", async () => { - let phase = 0 - const h = install({ - statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, - existing: () => (phase === 0 ? null : { ...ours, enabled: false }), - projectEntry: () => { if (phase === 0) return null; throw new Error("EACCES on re-read") }, - }) - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "entry-disabled" }) - expect(h.restores, "a failed re-read fell back to restoring the snapshot: the user's disabled node is removed").not.toEqual([null]) - }) - test("AB-3: the disable landed on the GLOBAL entry (merged says disabled, project node is ours, enabled) → our node is removed, global untouched", async () => { - let phase = 0 - const h = install({ - statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, - existing: () => (phase === 0 ? null : { type: "local", command: ["datamate", "start-stdio"], enabled: false }), - projectEntry: () => (phase === 0 ? null : ours), - }) - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "entry-disabled" }) - expect(h.restores).toEqual([null]) - }) -}) - -describe("AD — in-region refusals undo BEFORE announcing; the finally is idempotent", () => { - test("post-add connect-failed: remove and persistRestore precede notify; exactly one remove and one restore", async () => { - const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) - const outcome = await ensure("s1") - expect(outcome).toMatchObject({ kind: "connect-failed" }) - const iRemove = h.trace.indexOf("remove"), iRestore = h.trace.indexOf("persistRestore"), iNotify = h.trace.indexOf("notify") - expect(iRemove).toBeGreaterThanOrEqual(0) - expect(iRestore).toBeGreaterThan(iRemove) - expect(iNotify, `trace: ${h.trace.join(" > ")}`).toBeGreaterThan(iRestore) - expect(h.removes).toEqual(["datamate"]) - expect(h.restores).toEqual([null]) - expect(h.toasts).toHaveLength(1) - }) - test("persist refused as 'disabled' → nothing installed, nothing undone, entry-disabled announced once", async () => { - const h = install({ statuses: [{}] }) - syncInternals.persist = async () => "disabled" - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "entry-disabled" }) - expect(h.added).toHaveLength(0) - expect(h.restores).toHaveLength(0) - expect(h.toasts).toHaveLength(1) - }) -}) - -describe("W — an undo that fails is announced once, naming the file; the triggering outcome survives", () => { - test("post-add connect-failed + restore failed → two toasts (engine failed; config left behind in ), outcome connect-failed", async () => { - const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) - syncInternals.persistRestore = async () => "failed" - const outcome = await ensure("s1") - expect(outcome).toMatchObject({ kind: "connect-failed", error: "exit 1" }) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind", "Workspace engine failed to start"]) - expect(h.toasts[0]!.message).toContain("/tmp/test/.altimate-code/altimate-code.json") - }) - test("superseded + restore failed → exactly one toast (config left behind), outcome superseded", async () => { - let current: CachedBinding | null = binding - const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) - syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } - syncInternals.persistRestore = async () => { throw new Error("EACCES") } - const outcome = await ensure("s1") - expect(outcome).toEqual({ kind: "superseded" }) - expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) - }) -}) - -// RENAMED ON LIFT: no longer a residual. A client this attach started is -// torn down whatever is bound now, which is what the teardown split said -// all along — the definition was right and the plumbing did not carry it -// as far as this exit. -describe("INVARIANT — a client we started is torn down whatever is bound now", () => { - test("(i) revive succeeds, then the re-inspection read THROWS → revived client left connected, outcome connect-failed via the catch-all", async () => { - let reads = 0 - const h = install({ - statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], - existing: () => { reads += 1; if (reads >= 3) throw new Error("config unreadable"); return ours }, - tools: { datamate_dbt_build_model: 1 }, - }) - const outcome = await ensure("s1") - expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) - expect(outcome.kind).toBe("connect-failed") - expect(h.removes, "the client this attach started is left registered and connected under a connect-failed outcome").toContain("datamate") - }) - test("(ii) revive succeeds, the file is rewritten unpinned and the binding moves: the revived client is still torn down", async () => { - let current: CachedBinding | null = binding - let reads = 0 - const h = install({ - statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], - existing: () => { reads += 1; return reads >= 3 ? { type: "local", command: ["datamate", "start-stdio"] } : ours }, - tools: { datamate_dbt_build_model: 1 }, - }) - syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) - const prevAdd = syncInternals.mcp!.add - syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } - const outcome = await ensure("s1") - expect(h.added).toHaveLength(1) - expect(outcome).toEqual({ kind: "superseded" }) - expect(h.removes, "the client this attach started is left registered and connected").toContain("datamate") - }) -}) diff --git a/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts b/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts deleted file mode 100644 index 8a78b21224..0000000000 --- a/packages/opencode/test/altimate/workspace/gate-r3-ah.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Round-3 AH/T probes — NOT for commit. -import { afterEach, beforeEach, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, planForEntry, settledOutcome } from "../../../src/altimate/workspace/engine-sync" -beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) -afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) - -test("AH: UNBOUND project, config read throws in the diagnostic branch → must stay `unbound` and silent", async () => { - const toasts: { title: string }[] = [] - syncInternals.resolveBinding = async () => null // unbound - syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } - syncInternals.notify = async (t) => { toasts.push(t) } - syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } - const out = await ensure("s1") - // GIVEN AN ASSERTION ON LIFT — this was the reviewer's unasserted observation. - void 0; console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) - expect(out.kind).toBe("unbound") - expect(toasts).toHaveLength(0) -}) - -test("T phantom: entry null + synthesised status (key known to MCP but not to config)", () => { - const noRuntime = planForEntry({ entry: null, observed: { status: "failed", error: "exit 1" }, runtime: undefined }, "42", false) - const withRuntime = planForEntry({ entry: null, observed: { status: "connected" }, runtime: { type: "local", command: ["datamate", "start-stdio"] } }, "42", false) - console.log("T phantom noRuntime:", JSON.stringify(noRuntime), "| withRuntime:", JSON.stringify(withRuntime)) -}) - -test("AH: the unbound escalation repeats every turn (connect-failed is REPAIRABLE)", async () => { - const toasts: { title: string }[] = [] - syncInternals.resolveBinding = async () => null - syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } - syncInternals.notify = async (t) => { toasts.push(t) } - syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } - await ensure("s1"); await ensure("s1"); await ensure("s1") - // GIVEN AN ASSERTION ON LIFT — it was the reviewer's unasserted observation, - // which could not fail and so protected nothing. An unbound project announces - // nothing at all, on any turn, whatever fails inside it. - expect(toasts, `an unbound project announced ${toasts.length} times`).toHaveLength(0) -}) diff --git a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts b/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts deleted file mode 100644 index ef12f3c74e..0000000000 --- a/packages/opencode/test/altimate/workspace/l3-snapshot.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -// L3 gate experiments (v2, against 2d8bea2d0) — snapshot freshness around planForEntry / Inspection. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" -import type { CachedBinding } from "../../../src/altimate/workspace/state" -import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" - -const binding: CachedBinding = { - datamateId: 42, - datamateName: "analytics", - repoRemote: "git@github.com:acme/analytics.git", - projectPath: "/tmp/analytics", -} as CachedBinding - -type H = { - added: Array<{ name: string; cfg: LocalMcpConfig }> - persisted: Array<{ name: string; cfg: LocalMcpConfig }> - connects: string[] - removes: string[] - toasts: string[] - statusQueue: Array> - reads: Array - probes: string[] -} - -function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null): H { - const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], statusQueue: statuses, reads: [], probes: [] } - syncInternals.resolveBinding = async () => binding - syncInternals.which = () => "/usr/local/bin/datamate" - syncInternals.versionOf = async (bin) => { - h.probes.push(bin) - return "0.7.0" - } - syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - } - syncInternals.existingEntry = async () => { - const e = entry() - h.reads.push(e?.enabled) - return e - } - syncInternals.notify = async (t) => { - h.toasts.push(t.title) - } - syncInternals.toolsChanged = async () => {} - syncInternals.persistRestore = async () => {} - syncInternals.projectEntry = async () => null - syncInternals.mcp = { - status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), - add: async (name, cfg) => { - h.added.push({ name, cfg }) - }, - remove: async (name) => { - h.removes.push(name) - }, - tools: async () => ({ datamate_dbt_build_model: 1 }), - } - // The project file has no entry of its own unless a test says otherwise. - // Required since the project reader stopped swallowing its own errors. - if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null - if (!syncInternals.projectConfigPath) - syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" - return h -} - -beforeEach(() => { - process.env.ALTIMATE_WORKSPACE = "1" - resetForTests() -}) -afterEach(() => { - for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] -}) - -describe("L3 (a') — a disable lands INSIDE the retry's connect window", () => { - test("FIXED by 5fe9d8a6a: the retry re-inspects both halves, so a disable that survives on disk is honoured", async () => { - let enabled = true - const h = install( - [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), - ) - // ADAPTED ON LIFT: the retry re-adds instead of connecting. - const previousAddA = syncInternals.mcp!.add - syncInternals.mcp!.add = async (name, cfg) => { - enabled = false - return previousAddA(name, cfg) - } - const outcome = await ensure("s1") - expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) - expect(h.reads, "inspection, pre-revive guard, re-inspection").toEqual([true, true, false]) // two inspections - expect(outcome.kind).toBe("entry-disabled") - expect(h.removes).toEqual(["datamate"]) - }) - - // REMOVED ON LIFT — staged by hooking `MCP.connect`, which the attach flow no - // longer has. Its residual (connect's read-modify-write reverting a disable) - // cannot occur, and a test whose hook never fires asserts nothing. -}) - -// REMOVED ON LIFT — this describe staged its scenario by hooking `MCP.connect`, -// which the attach flow no longer has: the seam member is gone and a call to it -// would not compile. Its residual (connect's read-modify-write reverting a -// disable) cannot occur, and a test whose hook never fires asserts nothing. -// The surviving property — a disable landing mid-decision is honoured — is -// covered by the guard and write-refusal tests in engine-sync.test.ts. - -describe("L3 (f) — the plan derived from an Inspection is held across the probes, then persist writes enabled:true", () => { - test("replace-unattributable: a disable landing during the PATH probe is persisted over, and the memo never re-checks", async () => { - // The extension's own entry: unpinned, live. Rule 1 replaces it. - let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } - const h = install( - [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], - () => onDisk, - ) - // The user disables the entry while the flow is probing `datamate --version` - // on PATH (seconds: declaredBounded up to 4s, versionOf ~1s, projectEntry). - syncInternals.versionOf = async (bin) => { - h.probes.push(bin) - onDisk = { ...onDisk, enabled: false } - return "0.7.0" - } - // persist() replaces the whole `mcp.datamate` node in the project file - // (mcp/config.ts:54-59), so a later fresh read returns OUR entry. - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } - } - const first = await ensure("s1") - // INVERTED ON LIFT. This file documents current behaviour, and the behaviour - // it documented was the defect: the plan was held across the probes and then - // persisted our `enabled: true` over a disable that had landed meanwhile, - // after which the memo read our own entry and stood forever. The guard - // re-reads intent as well as the binding now, so the write never happens — - // and it reports WHICH half moved, so the user learns their edit took - // effect rather than being told about a generic race. - expect(first.kind).toBe("entry-disabled") - expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) - expect(h.added, "installed over a disable that landed during the probes").toHaveLength(0) - - // Next turn: the memo validator reads fresh config — which is now our pinned, enabled entry. - const second = await ensure("s1") - // The next turn re-decides rather than riding a memo: it reads the disable - // and reports it by name. - expect(second.kind).toBe("entry-disabled") - // Three teardowns now, all correct: the pre-spawn detach of the unpinned - // entry, the disabled entry's teardown when the guard catches the disable - // before the write, and its teardown again on the next turn. A disabled - // entry serves nothing, so it is never left registered. - expect(h.removes).toEqual(["datamate", "datamate", "datamate"]) - }) - - test("same shape on the pinned-but-below-floor path", async () => { - let onDisk: ExistingEntry = { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"], enabled: true } - const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) - syncInternals.versionOf = async (bin) => { - h.probes.push(bin) - if (bin.startsWith("/opt/old")) return "0.6.3" - onDisk = { ...onDisk, enabled: false } // disable lands during the PATH probe - return "0.7.0" - } - syncInternals.persist = async (name, cfg) => { - h.persisted.push({ name, cfg }) - onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } - } - const first = await ensure("s1") - // INVERTED ON LIFT — same shape, same fix. - expect(first.kind).toBe("entry-disabled") - expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) - }) - - test("control: a disable that lands BEFORE the inspection is honoured on the same entry", async () => { - const h = install([{ datamate: { status: "connected" } }], () => ({ - type: "local", - command: ["datamate", "start-stdio"], - enabled: false, - })) - expect((await ensure("s1")).kind).toBe("entry-disabled") - expect(h.persisted).toHaveLength(0) - }) -}) - -describe("L3 (a)/(b) — edits between the two reads inside inspectEntry (no retry)", () => { - test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { - let enabled = true - const h = install([{ datamate: { status: "connected" } }], () => ({ - type: "local", - command: ["datamate", "start-stdio", "--datamate", "42"], - enabled, - })) - const realStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { - enabled = false - return realStatus() - } - expect((await ensure("s1")).kind).toBe("reused") - expect(h.persisted).toEqual([]) - expect((await ensure("s1")).kind).toBe("entry-disabled") - expect(h.removes).toEqual(["datamate"]) - }) - - test("(b) re-enable after the config read → honour-disable on the stale half, config untouched, next turn repairs", async () => { - let enabled = false - const h = install( - [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], - () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), - ) - const realStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { - enabled = true - return realStatus() - } - expect((await ensure("s1")).kind).toBe("entry-disabled") - expect(h.persisted).toEqual([]) - expect(["reused", "attached"]).toContain((await ensure("s1")).kind) - }) - - test("(inverted round-12) IDE adds the entry after the config read → spawn persists over it, unreported", async () => { - let onDisk: ExistingEntry | null = null - const h = install([{}, { datamate: { status: "connected" } }], () => onDisk) - const realStatus = syncInternals.mcp!.status - syncInternals.mcp!.status = async () => { - onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } // IDE sync lands here - return realStatus() - } - const outcome = await ensure("s1") - expect(outcome.kind).toBe("attached") - expect((outcome as { replaced?: string }).replaced).toBeUndefined() - expect(h.persisted).toHaveLength(1) - expect(h.removes).toEqual([]) - }) -}) diff --git a/packages/opencode/test/altimate/workspace/launch-resolve.test.ts b/packages/opencode/test/altimate/workspace/launch-resolve.test.ts index 21ecd4fc9e..5dec92fc54 100644 --- a/packages/opencode/test/altimate/workspace/launch-resolve.test.ts +++ b/packages/opencode/test/altimate/workspace/launch-resolve.test.ts @@ -144,7 +144,7 @@ describe("resolveWorkspaceForLaunch", () => { expect(getResolvedWorkspaceId()).toBe(42) }) - test("mismatched name → env var STILL set (attaches to linked workspace with a note per AI-8504 spec)", async () => { + test("a mismatched name still attaches to the linked workspace, with a note", async () => { await resolveWorkspaceForLaunch(DIRECTORY, "Other") expect(getResolvedWorkspaceId()).toBe(42) }) diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts new file mode 100644 index 0000000000..50c406100e --- /dev/null +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -0,0 +1,767 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig, planForEntry, installWouldHelp, whenAttached, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("the world check sits adjacent to every mutation", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + + const A: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + const B: CachedBinding = { ...A, datamateId: 99, datamateName: "other" } as CachedBinding + + type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: unknown[] + statusQueue: Array> + tools: Record + /** Every awaited seam, in call order, with the binding it observed. */ + trace: string[] + current: CachedBinding | null + } + + function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: Harness["statusQueue"] + tools?: Record + existing?: ExistingEntry | null + }): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + restores: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + trace: [], + current: A, + } + const seam = (name: string) => h.trace.push(name) + syncInternals.resolveBinding = async () => (seam("resolveBinding"), h.current) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + seam("versionOf") + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => (seam("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + seam("persist") + h.persisted.push({ name, cfg }) + } + syncInternals.projectEntry = async () => (seam("projectEntry"), null) + syncInternals.existingEntry = async () => { + seam("existingEntry") + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.notify = async (toast) => { + seam("notify") + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + seam("toolsChanged") + } + syncInternals.persistRestore = async (_name, previous) => { + seam("persistRestore") + h.restores.push(previous ?? null) + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (seam("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + seam("add") + h.added.push({ name, cfg }) + }, + remove: async (name) => { + seam("remove") + h.removes.push(name) + }, + tools: async () => (seam("tools"), h.tools), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + }) + + // --------------------------------------------------------------------------- + // T1 — the property the author names, tested as a property: the seam awaited + // IMMEDIATELY before every mutation must be the binding read. Catches any + // awaited seam inserted between the guard and persist/add/remove/connect, + // which the existing first-call-flip tests cannot (they flip before the guard). + // --------------------------------------------------------------------------- + describe("the last awaited seam before every mutation is the world check", () => { + const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) + + /** Which teardowns in a scenario are binding-DEPENDENT. + * + * The split is the point: a teardown that undoes what this attach created, or + * that stops a disabled or below-floor engine, is right whatever the project + * is bound to now — requiring a binding read before those would assert the + * opposite of what they are for. Only acting on a pre-existing entry we did + * not create depends on the binding. Scenarios declare which kind they + * exercise, because the trace cannot tell them apart. */ + function violations(trace: string[], removesAreBindingDependent = true): string[] { + const out: string[] = [] + for (let i = 0; i < trace.length; i++) { + if (!MUTATIONS.has(trace[i])) continue + // Walk back to the previous non-mutation seam. + let j = i - 1 + while (j >= 0 && MUTATIONS.has(trace[j])) j-- + const before = trace[j] + const beforeThat = trace[j - 1] + // persist→add is the one sanctioned adjacency (persist has no seam of its own + // to re-read after); everything else must sit directly on the world check. + if (trace[i] === "add" && trace[i - 1] === "persist") continue + // the world check is now TWO reads in a fixed order — + // binding, then intent — because a guard that confirms only the binding is + // a guard on half the world. Intent goes last so the only thing between + // confirming it and the write is the write's own read of the node it + // replaces, which checks again where nothing can intervene. + // A WRITE needs the whole world (intent forbids creating anything); a + // TEARDOWN needs only the binding, since intent neither authorises nor + // forbids stopping a client. + const isWrite = trace[i] === "persist" || trace[i] === "add" + if (isWrite && before === "resolveBinding" && beforeThat === "existingEntry") continue + if (!isWrite && !removesAreBindingDependent) continue + if (!isWrite && before === "resolveBinding") continue + out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) + } + return out + } + + test("fresh spawn", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + test("replace an unpinned live entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + // Its teardown is binding-INDEPENDENT: an engine below the floor serves + // nobody correctly whatever is bound now. + test("pinned-but-below-floor, PATH newer", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace, false), h.trace.join(" > ")).toEqual([]) + }) + + test("retry-connect of a down command entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + }) + + // --------------------------------------------------------------------------- + // T2 — retry-connect on a stale binding, then the refusal skips teardown + // because the binding is stale: the engine THIS attach brought up stays. + // --------------------------------------------------------------------------- + describe("reviving an engine is a guarded mutation", () => { + test("a re-link before the retry: the engine we reconnected is left serving under the new binding", async () => { + const h = install({ + // Pinned to 42, down, and (once revived) below the floor; PATH no better. + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // The re-link lands while the config is being read — before the retry. + syncInternals.existingEntry = async () => { + h.trace.push("existingEntry") + h.current = B + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + const outcome = await ensure("s1") + // It is never started now: the retry is a guarded mutation, so a binding that + // moved before it means we abandon rather than start-then-undo. Nothing + // brought up is strictly better than something brought up and removed. + expect(h.connects, "reconnected an entry for a workspace the project had already left").toEqual([]) + expect(h.added, "started an engine for a workspace the project had already left").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link DURING the retry's connect window: same result", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // The retry re-adds rather than connecting, so the window a + // re-link can land in is `add`, not `connect`. + const previousAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + h.trace.push("add") + h.current = B // a TUI re-link inside the restart is the likely timing + return previousAdd(name, cfg) + } + const outcome = await ensure("s1") + // The engine THIS attach brought up is torn down whatever is bound now — + // undoing what we created is binding-independent by definition. + expect(h.removes, "the engine this attach brought up was left connected under binding 99").toContain("datamate") + expect(outcome.kind).toBe("superseded") + }) + }) + + // --------------------------------------------------------------------------- + // T3 — production persist() awaits ~10 fs operations (resolveConfigPath's + // exists() loop, addMcpToConfig's exists+readText) before its write and before + // MCP.add. Model ONE of them in the seam and flip inside it. + // --------------------------------------------------------------------------- + describe("no await separates the final check from the write it guards", () => { + test("a re-link inside persist's config-path probe still spawns the old workspace's engine", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + // The config-path probe — up to nine `exists` calls — is no + // longer inside the write: it is resolved ABOVE the guard and handed in, so + // this models it where it now lives. That is the fix; flipping inside the + // resolved-path lookup must be caught by the guard, not undone after it. + syncInternals.projectConfigPath = async () => { + h.trace.push("resolveConfigPath") + await Promise.resolve() // Filesystem.exists(candidate) #1 of up to 9 + h.current = B + return "/tmp/test/.altimate-code/altimate-code.json" + } + const outcome = await ensure("s1") + // Round 19's own standard: the late guard undoing it is the failure, not the fix. + expect(h.added.filter((a) => a.cfg.command.includes("42")), "spawned workspace 42's engine after the re-link").toHaveLength(0) + expect(h.persisted, "wrote workspace 42's pin after the re-link").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link inside the WRITE itself is undone rather than prevented — the named residual", async () => { + // Nothing can guard the inside of the write. What must hold is that the + // region gives back both halves of what it took. + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + h.current = B + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine registered").toContain("datamate") + expect(h.restores.length, "left the old workspace's pin on disk").toBeGreaterThan(0) + }) + }) + + // --------------------------------------------------------------------------- + // T4 — answered after awaits that follow the final guard (announce, notify). + // --------------------------------------------------------------------------- + describe("the attached answer is fixed before it is announced", () => { + test("a re-link during announceToolsChanged is answered `attached` for the old workspace", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.toolsChanged = async () => { + h.trace.push("toolsChanged") + h.current = B + } + const outcome = await ensure("s1") + // The answer is now fixed BEFORE the announcements rather than after them, + // so the decision no longer straddles those awaits — but a re-link landing + // inside the toast still leaves this turn holding `attached` for 42. It + // cannot be guarded without either un-saying a toast already shown or + // announcing a success we then retract. + // + // What must hold is that it does not OUTLIVE the turn: the memo is keyed to + // the workspace it was taken for, so the next turn re-decides for 99 rather + // than riding it. + expect(outcome.kind).toBe("attached") + const second = await ensure("s1") + expect(second.kind, "rode a memo taken for the workspace the project had left").not.toBe("reused") + expect(h.added.at(-1)?.cfg.command, "did not re-attach for the new binding").toEqual([ + "datamate", + "start-stdio", + "--datamate", + "99", + ]) + }) + }) + + // --------------------------------------------------------------------------- + // T5 — the skip-teardown in detachRejected applies to binding-INDEPENDENT + // teardowns too: a disabled entry keeps serving for this turn after a re-link. + // --------------------------------------------------------------------------- + describe("a disabled entry is torn down whatever is bound now", () => { + test("re-link during the status read: the disabled-but-connected client is left serving", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.mcp!.status = async () => { + h.trace.push("status") + h.current = B + return { datamate: { status: "connected" } } + } + const outcome = await ensure("s1") + // The teardown is the property under test: a + // disabled entry is disabled for every workspace, so its teardown does not + // consult the binding. The ANSWER is now `superseded` rather than + // `entry-disabled`, because a refusal is an answer too and this one would + // otherwise describe — and toast about — a workspace the project has left. + expect(outcome.kind).toBe("superseded") + expect(h.removes, "a disabled entry is disabled for every workspace; its teardown does not depend on the binding").toContain("datamate") + }) + }) +}) + +describe("a mutation is never made on a world that has moved", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + probes: string[] + } + + function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], statusQueue: statuses, reads: [], probes: [] } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + return "0.7.0" + } + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + remove: async (name) => { + h.removes.push(name) + }, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + describe("a disable landing inside the revive window", () => { + test("the revive re-inspects both halves, so a disable that survives on disk is honoured", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + // The retry re-adds instead of connecting. + const previousAddA = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + enabled = false + return previousAddA(name, cfg) + } + const outcome = await ensure("s1") + expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) + expect(h.reads, "inspection, pre-revive guard, re-inspection").toEqual([true, true, false]) // two inspections + expect(outcome.kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + + // staged by hooking `MCP.connect`, which the attach flow no + // longer has. Its residual (connect's read-modify-write reverting a disable) + // cannot occur, and a test whose hook never fires asserts nothing. + }) + + // this describe staged its scenario by hooking `MCP.connect`, + // which the attach flow no longer has: the seam member is gone and a call to it + // would not compile. Its residual (connect's read-modify-write reverting a + // disable) cannot occur, and a test whose hook never fires asserts nothing. + // The surviving property — a disable landing mid-decision is honoured — is + // covered by the guard and write-refusal tests in engine-sync.test.ts. + + describe("a plan held across the probes never writes over a disable", () => { + test("replace-unattributable: a disable landing during the PATH probe is persisted over, and the memo never re-checks", async () => { + // The extension's own entry: unpinned, live. Rule 1 replaces it. + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + ) + // The user disables the entry while the flow is probing `datamate --version` + // on PATH (seconds: declaredBounded up to 4s, versionOf ~1s, projectEntry). + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + onDisk = { ...onDisk, enabled: false } + return "0.7.0" + } + // persist() replaces the whole `mcp.datamate` node in the project file + // (mcp/config.ts:54-59), so a later fresh read returns OUR entry. + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // it documented was the defect: the plan was held across the probes and then + // persisted our `enabled: true` over a disable that had landed meanwhile, + // after which the memo read our own entry and stood forever. The guard + // re-reads intent as well as the binding now, so the write never happens — + // and it reports WHICH half moved, so the user learns their edit took + // effect rather than being told about a generic race. + expect(first.kind).toBe("entry-disabled") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + expect(h.added, "installed over a disable that landed during the probes").toHaveLength(0) + + // Next turn: the memo validator reads fresh config — which is now our pinned, enabled entry. + const second = await ensure("s1") + // The next turn re-decides rather than riding a memo: it reads the disable + // and reports it by name. + expect(second.kind).toBe("entry-disabled") + // Three teardowns now, all correct: the pre-spawn detach of the unpinned + // entry, the disabled entry's teardown when the guard catches the disable + // before the write, and its teardown again on the next turn. A disabled + // entry serves nothing, so it is never left registered. + expect(h.removes).toEqual(["datamate", "datamate", "datamate"]) + }) + + test("same shape on the pinned-but-below-floor path", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + if (bin.startsWith("/opt/old")) return "0.6.3" + onDisk = { ...onDisk, enabled: false } // disable lands during the PATH probe + return "0.7.0" + } + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // + expect(first.kind).toBe("entry-disabled") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + }) + + test("control: a disable that lands BEFORE the inspection is honoured on the same entry", async () => { + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio"], + enabled: false, + })) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("edits landing between the two reads of one inspection, with no revive", () => { + test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + })) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("reused") + expect(h.persisted).toEqual([]) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + + test("(b) re-enable after the config read → honour-disable on the stale half, config untouched, next turn repairs", async () => { + let enabled = false + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = true + return realStatus() + } + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) + expect(["reused", "attached"]).toContain((await ensure("s1")).kind) + }) + + test("an entry an IDE adds after the config read is seen, not spawned over persists over it, unreported", async () => { + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }], () => onDisk) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } // IDE sync lands here + return realStatus() + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("attached") + expect((outcome as { replaced?: string }).replaced).toBeUndefined() + expect(h.persisted).toHaveLength(1) + expect(h.removes).toEqual([]) + }) + }) +}) + +describe("a reused engine is re-judged, never assumed", () => { + const binding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("planForEntry: a disable marker with no runtime status is honoured", () => { + // MCP.status() omits a config entry that has no `type` (mcp/index.ts:875-878), + // and the schema allows `{ enabled: false }` alone (core config.ts:119). + expect(planForEntry({ entry: { enabled: false }, observed: undefined }, "42", false)).toEqual({ act: "honour-disable" }) + }) + + test("ensure: a project `datamate: { enabled: false }` marker is not spawned over", async () => { + const added: unknown[] = [], persisted: unknown[] = [], toasts: unknown[] = [] + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => ({ enabled: false }) + syncInternals.projectEntry = async () => ({ enabled: false }) + syncInternals.persist = async (n, c) => { persisted.push({ n, c }) } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + let live = false + syncInternals.mcp = { + // The entry has no `type`, so status() never lists it — until WE add it. + status: async () => (live ? { datamate: { status: "connected" } } : {}), + add: async (n, c) => { added.push({ n, c }); live = true }, + remove: async () => {}, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + const outcome = await ensure("s1") + console.log("outcome:", JSON.stringify(outcome), "persisted:", JSON.stringify(persisted), "toasts:", JSON.stringify(toasts.map((t: any) => t.title))) + expect(outcome.kind).toBe("entry-disabled") + expect(added).toHaveLength(0) + expect(persisted).toHaveLength(0) + }) +}) + +describe("what a decision may conclude from an entry it did not create", () => { + const b42 = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + const b99 = { datamateId: 99, datamateName: "other", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + + type H = { added: unknown[]; persisted: unknown[]; connects: string[]; removes: string[]; toasts: { title: string; message: string }[] } + function base(opts: { existing: unknown; statuses: Record[]; which?: string | null; binding?: () => CachedBinding | null }): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [] } + const q = opts.statuses + syncInternals.resolveBinding = async () => (opts.binding ? opts.binding() : b42) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => opts.existing as never + syncInternals.projectEntry = async () => null + syncInternals.persist = async (n, c) => { h.persisted.push({ n, c }) } + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!), + add: async (n, c) => { h.added.push({ n, c }) }, + remove: async (n) => { h.removes.push(n) }, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("(a) the repair turn RECONNECTS the entry this flow tore down last turn, then rejects it again", async () => { + let onPath: string | null = null + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "disabled" } }, // synthesised by MCP.status() after OUR remove (mcp/index.ts:877) + { datamate: { status: "connected" } }, // MCP.connect brought the rejected engine back + { datamate: { status: "connected" } }, + ], + }) + syncInternals.which = () => onPath + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 1 }) + expect(h.removes).toEqual(["datamate"]) + onPath = "/usr/local/bin/datamate" + await ensure("s1") + console.log("(a) turn2 connects:", h.connects, "removes:", h.removes, "added:", h.added.length) + expect(h.connects, "reconnected an engine judged unattributable one turn earlier").toHaveLength(0) + }) + + test("(b) an entry REMOVED from config but still known to the runtime is retried via MCP's runtime cfg", async () => { + // MCP.status() lists every key in s.config (mcp/index.ts:880-882) — runtime cfg + // set by our own earlier MCP.add and never cleared by MCP.remove (949-955). + // An entry MCP still knows about but + // config no longer contains cannot be attributed to this workspace, so it is + // replaced rather than revived from whatever MCP happens to have retained. + expect(planForEntry({ entry: null, observed: { status: "disabled" } }, "42", false)).toMatchObject({ + act: "replace-unattributable", + pinnedTo: null, + }) + const h = base({ existing: null, statuses: [{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }] }) + const out = await ensure("s1") + console.log("(b) outcome:", JSON.stringify(out), "connects:", h.connects, "removes:", h.removes) + expect(h.connects).toEqual([]) // fails: connect("datamate") reconnects whatever s.config holds — planForEntry never saw it + }) + + test("(c) connect-failed with the engine binary gone: install would help, table says no", async () => { + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [ + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + ], + which: null, + }) + const out = await ensure("s1") + console.log("(c) outcome:", JSON.stringify(out), "toast:", h.toasts.map((t) => t.message)) + // `connect-failed` with the binary gone was a lie — the engine did not fail to + // start, there was no engine — so the outcome now says `engine-missing` and + // the remedy predicate is right about it without needing a special case. + // `which` is consulted before answering, rather than reading ENOENT out of a + // platform-specific message. + expect(out.kind).toBe("engine-missing") + expect(installWouldHelp(out)).toBe(true) + expect(h.toasts[0]?.message, "told the user it failed to start rather than that it is missing").toContain( + "not installed", + ) + }) + + test("(d) a refusal is answered for a binding the project already left, with the rejected client left serving", async () => { + let current = b42 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned, connected + statuses: [{ datamate: { status: "connected" } }], + which: null, + binding: () => current, + }) + // Re-link lands right after run() snapshots the binding (during the config read). + const realExisting = syncInternals.existingEntry! + syncInternals.existingEntry = async (n) => { current = b99; return realExisting(n) } + const out = await ensure("s1") + console.log("(d) outcome:", JSON.stringify(out), "removes:", h.removes, "toasts:", h.toasts.map((t) => t.message)) + expect(out.kind).not.toBe("engine-missing") // fails: answers engine-missing for ws 42 while ws 99 is bound; detach skipped, toast names "analytics" + }) + + test("(e) a re-link during memo validation: the next attach is filed under the OLD key and loses its wait", async () => { + let current: CachedBinding = b42 + let calls = 0 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }], + binding: () => current, + }) + expect(await ensure("s1")).toMatchObject({ kind: "reused" }) + // Turn 2: engineStillOurs runs; the binding flips to 99 during its status read. + syncInternals.mcp!.status = async () => { calls += 1; if (calls === 1) current = b99; return { datamate: { status: "connected" } } } + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", String(current.datamateId)], enabled: true }) as never + const t2 = ensure("s1") + const started = Date.now() + await whenAttached("s1", 2000) + const waited = Date.now() - started + const settledAtResolve = settledOutcome("s1") + const out2 = await t2 + await ensure("s1") + // observation, not a test: it could not fail and so could not protect + // anything. + // + // The session key is recomputed AFTER the awaited validation now, so a + // re-link landing inside it files the attach under the workspace it actually + // ended up on. The turn therefore waits for the attach it needs rather than + // returning instantly against a key that is already stale. + // `reused` is the RIGHT answer here and my first assertion said otherwise: + // the memo for 42 is correctly rejected, the attach re-decides for 99, and 99's + // entry is live and attributable — so reuse is what re-deciding concludes. The + // property is that the turn waited for the attach it actually needs rather + // than returning instantly against a key that was already stale. + // Not elapsed time — that assertion was flaky by construction, since a fast + // path measures 0ms at `Date.now()` resolution and the suite duly failed on + // it. The property is that the wait was actually honoured: the attach has + // SETTLED by the time `whenAttached` returns, which is what "the turn waits + // for the attach it needs" means and what dropping the wait would break. + void waited + expect(settledAtResolve, "resolved the turn before the attach it needs had settled").toBeDefined() + expect(out2.kind).toBe("reused") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts b/packages/opencode/test/altimate/workspace/seam-contract.test.ts similarity index 96% rename from packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts rename to packages/opencode/test/altimate/workspace/seam-contract.test.ts index 111814c351..421d22d83c 100644 --- a/packages/opencode/test/altimate/workspace/l5-seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/seam-contract.test.ts @@ -1,4 +1,4 @@ -// Gate lens 5 — adversarial probes of the `settledOutcome` seam and the +// The `settledOutcome` seam and the // `pinnedWorkspace` parser against the precedence contract. Disposable. import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { @@ -117,7 +117,7 @@ afterEach(() => { }) // ───────────────────────────── P1: pure synchronous read ───────────────────────────── -describe("P1 — settledOutcome is a pure synchronous read", () => { +describe("settledOutcome is a pure synchronous read", () => { test("is a plain function whose body has no await/then and never touches the task", () => { expect(settledOutcome.constructor.name).toBe("Function") const src = settledOutcome.toString() @@ -148,7 +148,7 @@ describe("P1 — settledOutcome is a pure synchronous read", () => { }) // ───────────────────── P2: undefined means "not settled", never stale ───────────────────── -describe("P2 — undefined for in-flight AND never-attached; no premature or stale write", () => { +describe("undefined means in flight or never attached, and is never written early", () => { test("never attached → undefined; attributableEngine(undefined) → false", () => { expect(settledOutcome("nobody")).toBeUndefined() expect(attributableEngine(undefined)).toBe(false) @@ -229,7 +229,7 @@ describe("P2 — undefined for in-flight AND never-attached; no premature or sta expect(settledOutcome("s1")).toBe(oa) }) - test("OBSERVATION: on every later turn the memo is replaced by a fresh entry, so the seam reads undefined during re-validation", async () => { + test("the seam reads undefined while a memo is being re-validated", async () => { const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) const first = await ensure("s1") expect(settledOutcome("s1")).toBe(first) @@ -247,7 +247,7 @@ describe("P2 — undefined for in-flight AND never-attached; no premature or sta }) // ───────────────────────────── P3: allowlist {attached, reused} ───────────────────────────── -describe("P3 — the allowlist is exactly {attached, reused}", () => { +describe("only attached and reused are attributable", () => { test("SERVING is true for exactly the consumer's two kinds", () => { const serving = Object.entries(SERVING) .filter(([, v]) => v) @@ -281,7 +281,7 @@ describe("P3 — the allowlist is exactly {attached, reused}", () => { }) // ─────────────────────── P4: describes the engine serving THIS session ─────────────────────── -describe("P4 — the outcome describes the engine actually serving this session", () => { +describe("the outcome describes the engine actually serving this session", () => { test("`reused` is only emitted when the live entry's pin equals this binding; any other pin is replaced", async () => { const cases: Array<{ name: string; entry: ExistingEntry; want: "reused" | "attached" }> = [ { name: "pinned to us", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, want: "reused" }, @@ -371,7 +371,7 @@ describe("P4 — the outcome describes the engine actually serving this session" expect(second).not.toBe(first) }) - test("OBSERVATION: the pin compared is the CONFIG entry's; MCP.status carries no argv, so a config-only rewrite is indistinguishable from a reconnect", async () => { + test("a config-only rewrite is indistinguishable from a reconnect through status alone", async () => { // Harness: config says pinned-to-42 and status says connected. Nothing in // run() can tell whether the connected process was launched with that argv. install({ @@ -385,7 +385,7 @@ describe("P4 — the outcome describes the engine actually serving this session" }) // ───────────────────────────── P5: keyed by session ID ───────────────────────────── -describe("P5 — keyed by session ID", () => { +describe("attribution is keyed by session id", () => { test("two sessions in one project hold distinct outcomes", async () => { const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) await ensure("s1") // spawns → attached @@ -407,7 +407,7 @@ describe("P5 — keyed by session ID", () => { expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) }) - test("OBSERVATION: eviction can drop a settled outcome while the session is live (fails open)", async () => { + test("eviction may drop a settled outcome while its session is live, and fails open", async () => { install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) await ensure("s1") expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) @@ -417,7 +417,7 @@ describe("P5 — keyed by session ID", () => { expect(settledOutcome("s1")).toBeUndefined() }) - test("OBSERVATION: another session's teardown leaves this session's settled `attached` stale until its next turn", async () => { + test("another session's teardown leaves this session's outcome stale until its next turn", async () => { const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) await ensure("s1") syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }) @@ -431,7 +431,7 @@ describe("P5 — keyed by session ID", () => { }) // ───────────────────────────── P6: pinnedWorkspace table ───────────────────────────── -describe("P6 — pinnedWorkspace over every argv shape", () => { +describe("the pin is read from every argv shape", () => { const table: Array<{ name: string; entry: unknown; want: string | null | "THROWS" }> = [ // contract shapes { name: "opencode argv, two tokens", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, want: "5" }, @@ -501,7 +501,7 @@ describe("P6 — pinnedWorkspace over every argv shape", () => { // ───────────── 2d8bea2d0: the connect-retry re-inspection vs the memo ───────────── describe("the retry re-inspects, and never writes the memo early or twice", () => { - // ADAPTED ON LIFT. These were written against `MCP.connect`, which the retry + // The retry // no longer uses: connect writes `enabled: true` into whichever config owns // the entry, so a local repair became a global config write, and it started // whatever MCP had retained rather than the entry the decision examined. The diff --git a/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts b/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts new file mode 100644 index 0000000000..84c8c0f0f3 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts @@ -0,0 +1,38 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { ensure, resetForTests, syncInternals, planForEntry, settledOutcome } from "../../../src/altimate/workspace/engine-sync" + +describe("a project with no workspace linked stays silent", () => { + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("an unbound project whose config read throws stays unbound and silent", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null // unbound + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + const out = await ensure("s1") + void 0; console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) + expect(out.kind).toBe("unbound") + expect(toasts).toHaveLength(0) + }) + + test("T phantom: entry null + synthesised status (key known to MCP but not to config)", () => { + const noRuntime = planForEntry({ entry: null, observed: { status: "failed", error: "exit 1" }, runtime: undefined }, "42", false) + const withRuntime = planForEntry({ entry: null, observed: { status: "connected" }, runtime: { type: "local", command: ["datamate", "start-stdio"] } }, "42", false) + console.log("T phantom noRuntime:", JSON.stringify(noRuntime), "| withRuntime:", JSON.stringify(withRuntime)) + }) + + test("an unbound project stays silent on every turn, not just the first", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + await ensure("s1"); await ensure("s1"); await ensure("s1") + // which could not fail and so protected nothing. An unbound project announces + // nothing at all, on any turn, whatever fails inside it. + expect(toasts, `an unbound project announced ${toasts.length} times`).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts new file mode 100644 index 0000000000..df201bf2bc --- /dev/null +++ b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts @@ -0,0 +1,822 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { Config } from "../../../src/config/config" +import { Filesystem } from "../../../src/util/filesystem" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("every exit gives back what it took", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: Array + statusQueue: Array> + tools: Record + } + function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: H["statusQueue"] + tools?: Record + existing?: ExistingEntry | null + projectEntry?: ExistingEntry | null + }): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.projectEntry = async () => opts.projectEntry ?? null + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { h.restores.push(prev ?? null) } + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { h.added.push({ name, cfg }) }, + remove: async (name) => { h.removes.push(name) }, + tools: async () => h.tools, + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { + for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + }) + + describe("a supersede does not skip a teardown that does not depend on the binding", () => { + test("A: entry DISABLED + connected, re-link lands between status() and refuse → client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.resolveBinding = async () => current + const prevStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { const s = await prevStatus(); current = other; return s } + const outcome = await ensure("s1") + // The answer is + // `superseded` because a refusal is an answer too, and this one would have + // described a workspace the project had already left. + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "disabled entry reported but its live client was NOT removed (detachRejected skipped on supersede)").toContain("datamate") + }) + test("B: pinned-to-us, below floor, nothing better on PATH, re-link lands in versionOf → too-old client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => { current = other; return "0.5.0" }, + }) + syncInternals.resolveBinding = async () => current + const outcome = await ensure("s1") + // Teardown holds; the answer is `superseded`. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.removes, "too-old engine reported but left registered (detachRejected skipped on supersede)").toContain("datamate") + }) + }) + + describe("D — connect-failed AFTER install never restores what persist() replaced", () => { + test("user's hand-authored PROJECT entry is overwritten by our pin; spawn fails; nothing puts it back", async () => { + const users: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"] } // unpinned, in project file, live + const h = install({ + existing: users, + projectEntry: users, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "failed", error: "exit 1" } }], + }) + const outcome = await ensure("s1") + // The install region gives back both + // halves on every non-attached exit, so a failed spawn puts the user's own + // entry back instead of leaving our pin over it. + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.restores, "the failed spawn left our pin over the user's project entry").toEqual([users]) + }) + }) + + describe("F — connect-failed after install, superseded: stale pin stays on disk and wedges the new workspace", () => { + test("turn 1: install 42, re-link to 99 during add, spawn fails → refuse() without undoInstall", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + // A failed spawn is a non-attached + // exit, so the region gives back the pin it wrote — it does not survive to + // wedge the next turn. + // The re-link lands during the add, so the refusal revalidates and declines + // to answer for the workspace the project has left. + expect(outcome).toMatchObject({ kind: "superseded" }) + // Both halves: the pin WAS written, and it was given back. + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.restores.length, "the failed spawn's pin was left on disk to wedge the next turn").toBeGreaterThan(0) + }) + test("turn 2 under binding 99: the failing 42 pin is retried once and refused — 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + // Turn 1's re-link + // during the add makes the refusal decline to answer for the workspace just + // left, and its pin is given back rather than left to wedge turn 2. Turn 2 + // then judges 42's pin unattributable under binding 99 and REPLACES it + // instead of retrying it, so 99 gets its engine. `connect-failed` on turn 2 + // is the fixture's own doing: its status queue reports the freshly spawned + // engine as failed too. + expect(await ensure("s1")).toMatchObject({ kind: "superseded" }) + syncInternals.mcp!.add = prevAdd + const second = await ensure("s1") + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "workspace 99 never gets an engine: the stale failing 42 pin blocks it every turn").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + }) + + describe("E — a throw after install bypasses undoInstall entirely", () => { + test("re-link during add, then tools() throws → engine for 42 stays installed under binding 99, outcome connect-failed", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.mcp!.tools = async () => { throw new Error("tools listing exploded") } + const outcome = await ensure("s1") + // A throw does not unwind past the undo — the + // region gives back both halves on any non-attached exit, including one + // nobody wrote. And because this throw lands AFTER a re-link, it is now the + // same silent `superseded` as every other refusal for a workspace the + // project has left: answering would name the wrong workspace, and toasting + // about it would be worse. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.toasts, "announced a failure for the workspace the project had left").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.removes, "a throw left the client registered").toContain("datamate") + expect(h.restores, "a throw left our pin on disk").toHaveLength(1) + }) + }) + + describe("C — retry-connect calls MCP.connect on a global-only entry (persists enabled:true into the owning file)", () => { + test("a down, enabled, IDE-shaped entry is retried via MCP.connect", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, // IDE shape, no `enabled` field, lives in global + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + }) + await ensure("s1") + // Repairing a down + // IDE-shaped entry used `MCP.connect`, which persists `enabled: true` into + // the file that owns the entry — a global write from a local decision. + // + // Asserting only "connect was not called" is now vacuous, since the seam no + // longer carries it. What earns its place is that the repair happened, with + // the right primitive and the entry we judged, and wrote nothing. + // And the scenario no longer reaches the repair at all: an IDE-shaped entry + // is UNPINNED, so attribution replaces it before connectivity is ever + // consulted. What lands is our own pinned entry, written to the project + // config — not a global write to theirs, which was the defect. + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + }) + }) + + describe("a failing pin never blocks the workspace the project is bound to", () => { + test("clean attach of 42; user re-links to 99; 42's engine is now down → retried once, refused; 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "connected" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial (42's engine died) + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + current = other + const second = await ensure("s1") + // 42's pin is unattributable under + // binding 99, so it is replaced rather than retried, and 99 gets its engine. + // `connect-failed` here is the fixture's own doing — the status queue reports + // the freshly spawned engine as failed too. + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "99 blocked behind the failing 42 pin").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + }) + + describe("G — refuse() order at 2d8bea2d0: teardown runs BEFORE announceRefusal; a throwing announce relabels the outcome", () => { + test("disabled+connected entry, notify seam throws: client IS removed (teardown first), but outcome becomes connect-failed and a 2nd toast fires", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + let notifyCalls = 0 + syncInternals.notify = async (t) => { + notifyCalls += 1 + if (notifyCalls === 1) throw new Error("dialog surface exploded") + h.toasts.push(t) + } + const outcome = await ensure("s1") + expect(h.removes, "teardown did not run before the announce").toContain("datamate") + // A throwing announce no longer + // reaches the catch-all, so the verdict stands and no second toast fires. + expect(notifyCalls, "a failed announcement was retried through a second toast site").toBe(1) + expect(outcome.kind, "a throwing announce relabels entry-disabled as connect-failed").toBe("entry-disabled") + }) + }) +}) + +describe("the undo obeys the world it undoes into", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + const binding: CachedBinding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/analytics" } as CachedBinding + const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + type H = { trace: string[]; added: Array<{ name: string; cfg: LocalMcpConfig }>; persisted: Array<{ name: string; cfg: LocalMcpConfig }>; removes: string[]; toasts: Array<{ title: string; message: string; variant: string }>; restores: Array; statusQueue: Array>; tools: Record } + function install(opts: { which?: string | null; version?: string | null | ((bin: string) => string | null); statuses?: H["statusQueue"]; tools?: Record; existing?: ExistingEntry | null | (() => ExistingEntry | null); projectEntry?: ExistingEntry | null | (() => ExistingEntry | null) }): H { + const h: H = { trace: [], added: [], persisted: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + const t = (s: string) => h.trace.push(s) + syncInternals.resolveBinding = async () => (t("resolveBinding"), binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (t("versionOf"), typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => (t("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { t("persist"); h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { t("existingEntry"); if (typeof opts.existing === "function") return opts.existing(); if (opts.existing !== undefined) return opts.existing; const last = h.persisted[h.persisted.length - 1]; return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null } + syncInternals.projectEntry = async () => { t("projectEntry"); return typeof opts.projectEntry === "function" ? opts.projectEntry() : (opts.projectEntry ?? null) } + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.notify = async (tt) => { t("notify"); h.toasts.push(tt) } + syncInternals.toolsChanged = async () => { t("toolsChanged") } + syncInternals.persistRestore = async (_n, prev) => { t("persistRestore"); h.restores.push(prev ?? null) } + syncInternals.mcp = { status: async () => (t("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), add: async (name, cfg) => { t("add"); h.added.push({ name, cfg }) }, remove: async (name) => { t("remove"); h.removes.push(name) }, tools: async () => (t("tools"), h.tools) } + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k]; if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE; else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG }) + const ours: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + + describe("the undo reads the world at undo time", () => { + test("a disable landing on our node during the boot is kept, not deleted", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => (phase === 0 ? null : { ...ours, enabled: false }), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.removes).toContain("datamate") + expect(h.restores, "the user's disable was undone").toEqual([{ ...ours, enabled: false }]) + }) + test("an undo whose re-read throws does not write blind", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => { if (phase === 0) return null; throw new Error("EACCES on re-read") }, + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores, "a failed re-read fell back to restoring the snapshot: the user's disabled node is removed").not.toEqual([null]) + }) + test("a disable on the global entry leaves the project node ours to remove", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { type: "local", command: ["datamate", "start-stdio"], enabled: false }), + projectEntry: () => (phase === 0 ? null : ours), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores).toEqual([null]) + }) + }) + + describe("an in-region refusal undoes before it announces", () => { + test("post-add connect-failed: remove and persistRestore precede notify; exactly one remove and one restore", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed" }) + const iRemove = h.trace.indexOf("remove"), iRestore = h.trace.indexOf("persistRestore"), iNotify = h.trace.indexOf("notify") + expect(iRemove).toBeGreaterThanOrEqual(0) + expect(iRestore).toBeGreaterThan(iRemove) + expect(iNotify, `trace: ${h.trace.join(" > ")}`).toBeGreaterThan(iRestore) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + expect(h.toasts).toHaveLength(1) + }) + test("persist refused as 'disabled' → nothing installed, nothing undone, entry-disabled announced once", async () => { + const h = install({ statuses: [{}] }) + syncInternals.persist = async () => "disabled" + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.added).toHaveLength(0) + expect(h.restores).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) + }) + + describe("W — an undo that fails is announced once, naming the file; the triggering outcome survives", () => { + test("post-add connect-failed + restore failed → two toasts (engine failed; config left behind in ), outcome connect-failed", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.persistRestore = async () => "failed" + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed", error: "exit 1" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind", "Workspace engine failed to start"]) + expect(h.toasts[0]!.message).toContain("/tmp/test/.altimate-code/altimate-code.json") + }) + test("superseded + restore failed → exactly one toast (config left behind), outcome superseded", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.persistRestore = async () => { throw new Error("EACCES") } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) + }) + + // A client this attach started is + // torn down whatever is bound now, which is what the teardown split said + // all along — the definition was right and the plumbing did not carry it + // as far as this exit. + describe("INVARIANT — a client we started is torn down whatever is bound now", () => { + test("(i) revive succeeds, then the re-inspection read THROWS → revived client left connected, outcome connect-failed via the catch-all", async () => { + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; if (reads >= 3) throw new Error("config unreadable"); return ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + const outcome = await ensure("s1") + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(outcome.kind).toBe("connect-failed") + expect(h.removes, "the client this attach started is left registered and connected under a connect-failed outcome").toContain("datamate") + }) + test("(ii) revive succeeds, the file is rewritten unpinned and the binding moves: the revived client is still torn down", async () => { + let current: CachedBinding | null = binding + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; return reads >= 3 ? { type: "local", command: ["datamate", "start-stdio"] } : ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + expect(h.added).toHaveLength(1) + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "the client this attach started is left registered and connected").toContain("datamate") + }) + }) +}) + +describe("the restore refuses on the text it edits", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + removes: string[] + restores: Array + toasts: Array<{ title: string; message: string }> + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry + } + + function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null, opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}): H { + const h: H = { added: [], persisted: [], removes: [], restores: [], toasts: [], statusQueue: statuses, reads: [], spawnedNow: opts.spawned } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push({ title: t.title, message: t.message }) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { + h.restores.push(prev) + } + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + const DISABLED_FILE = JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } }, null, 2) + const PINNED42 = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } as ExistingEntry + + describe("the write checks the same text it modifies", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(async () => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-")), "altimate-code.json") + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + /** After the guard's intent read, config-file readText #1 is now addMcpToConfig's + * ONLY read (persist has no separate check read any more). */ + function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") { + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true }) + syncInternals.projectConfigPath = async () => file + let armed = false + let landed = false + let n = 0 + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (h.reads.length === 2) { + if (where === "intent-read-end" && !landed) { + landed = true + writeFileSync(file, DISABLED_FILE) + } + armed = true + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + Filesystem.readText = async (p: string) => { + if (!armed || p !== file || landed) return originalReadText(p) + n += 1 + if (n !== 1) return originalReadText(p) + landed = true + if (where === "before-write-read") { + writeFileSync(file, DISABLED_FILE) + return originalReadText(p) + } + const text = await originalReadText(p) + writeFileSync(file, DISABLED_FILE) + return text + } + return { h, reads: () => n } + } + + test("a disable landing after the guard is refused by the write's own read", async () => { + const { h } = stage("intent-read-end") + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) + + test("a disable landing before the write is refused", async () => { + const { h, reads } = stage("before-write-read") + const out = await ensure("s1") + console.log("W0/W2:", JSON.stringify(out), "disk:", JSON.stringify(await diskEntry()), "config reads after guard:", reads()) + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + }) + + test("a disable landing inside the write itself is lost — the named residual", async () => { + const { h } = stage("after-write-read") + const out = await ensure("s1") + const after = await diskEntry() + console.log("W3:", JSON.stringify(out), "disk:", JSON.stringify(after)) + expect(out.kind).toBe("attached") + expect(after?.enabled).toBe(true) + expect(after?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.added).toHaveLength(1) + expect(await ensure("s1")).toBe(out) + }) + }) + + describe("a config read that throws, at each read in turn", () => { + function realReader(throwAt: (n: number) => boolean, onDisk: () => ExistingEntry | null) { + const h = install([{}, { datamate: { status: "connected" } }], () => null) + delete syncInternals.existingEntry + let n = 0 + syncInternals.freshConfig = async () => { + n += 1 + if (throwAt(n)) throw new Error(n === 1 || throwAt(1) ? "EIO" : `EIO#`) + const e = onDisk() + return { mcp: e ? { datamate: e } : {} } + } + return { h, calls: () => n } + } + + test("read #1 (inspection) throws → connect-failed, 1 toast, no mutation", async () => { + const { h } = realReader((n) => n === 1, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: Error: EIO" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #2 (pre-install guard) throws → connect-failed, 1 toast, no mutation; same label as the inspection", async () => { + const { h } = realReader((n) => n === 2, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: intent could not be confirmed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #3 (post-install guard) throws → install undone, connect-failed, 1 toast", async () => { + const { h } = realReader((n) => n === 3, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(1) + expect(h.added).toHaveLength(1) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + }) + + test("undo re-read (projectEntry #2) throws → FAILS CLOSED: no restore, one left-behind toast, superseded", async () => { + let current: CachedBinding | null = binding + const h = install([{}, { datamate: { status: "connected" } }], () => null) + syncInternals.resolveBinding = async () => current + let pe = 0 + syncInternals.projectEntry = async () => { + pe += 1 + if (pe === 2) throw new Error("EIO undo re-read") + return null + } + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + const out = await ensure("s1") + expect(out.kind).toBe("superseded") + expect(pe).toBe(2) + expect(h.restores).toEqual([]) + expect(h.removes).toEqual(["datamate"]) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) + + test("memo validation read throws (transient) → not served, re-decided → reused; no toast", async () => { + const { h } = realReader((n) => n === 4, () => (h.added.length ? PINNED42 : null)) + const first = await ensure("s1") + expect(first.kind).toBe("attached") + h.statusQueue = [{ datamate: { status: "connected" } }] + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(second.kind).toBe("reused") + expect(h.toasts).toHaveLength(1) + }) + + test("PERSISTENT throw: three turns re-decide but announce ONCE (AL)", async () => { + const { h, calls } = realReader(() => true, () => null) + const a = await ensure("s1") + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AH persistent:", a.kind, b.kind, c.kind, "toasts:", h.toasts.length, "freshConfig calls:", calls()) + expect([a.kind, b.kind, c.kind]).toEqual(["connect-failed", "connect-failed", "connect-failed"]) + expect(h.toasts.length).toBe(1) + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("a probe that keeps failing is refused once, not every turn", () => { + test("turn 1: detach + refuse once (engine-too-old), client not left registered; later turns re-decide silently (AL)", async () => { + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => PINNED42, + { spawned: PINNED42 }, + ) + syncInternals.versionOf = async () => { + throw new Error("EACCES") + } + const a = await ensure("s1") + expect(a.kind).toBe("engine-too-old") + expect(h.removes).toEqual(["datamate"]) + expect(h.spawnedNow).toBeUndefined() + expect(h.toasts).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") + + // Turn 2: the outcome is REPAIRABLE, so the memo does not hold it — run() again. + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AJ:", b.kind, c.kind, "toasts:", h.toasts.length, "added:", h.added.length, "removes:", h.removes.length) + expect(b).not.toBe(a) + expect(h.toasts.length).toBe(1) + }) + }) + + describe("spawned cleared on onclose / disconnect — the next attach", () => { + test("child exit (record cleared, status failed) → revived via add → reused", async () => { + const h = install([{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], () => PINNED42) + const out = await ensure("s1") + expect(out.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.spawnedNow?.command).toEqual(PINNED42.command) + expect(h.persisted).toHaveLength(0) + }) + + test("disconnect (record cleared, status disabled, config enabled:false) → entry-disabled; then /mcp enable-style re-add → reused", async () => { + let enabled = true + let status: { status: string } = { status: "connected" } + const h = install([], () => ({ ...PINNED42, enabled }), { spawned: PINNED42 }) + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("reused") + // MCP.disconnect: closeClient, delete spawned, status disabled, persist enabled:false + enabled = false + status = { status: "disabled" } + h.spawnedNow = undefined + const mid = await ensure("s1") + expect(mid.kind).toBe("entry-disabled") + expect(h.added).toHaveLength(0) + // MCP.connect (prompt.ts /mcp enable): createAndStore → spawned set, status connected, persist enabled:true + enabled = true + status = { status: "connected" } + h.spawnedNow = PINNED42 + const back = await ensure("s1") + expect(back.kind).toBe("reused") + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("when a repeated verdict speaks again", () => { + test("same kind, changed detail (engine-too-old 0.5.9 → 0.6.0) speaks again", async () => { + let v = "0.5.9" + const h = install([{}], () => null) + syncInternals.versionOf = async () => v + expect((await ensure("s1")).kind).toBe("engine-too-old") + expect((await ensure("s1")).kind).toBe("engine-too-old") + v = "0.6.0" + expect((await ensure("s1")).kind).toBe("engine-too-old") + console.log("AL detail:", h.toasts.length, h.toasts.map((t) => t.message.slice(0, 40))) + expect(h.toasts.length).toBe(2) + }) + test("two sessions with the same verdict each hear it once", async () => { + const h = install([{}], () => null) + syncInternals.which = () => null + await ensure("a"); await ensure("a"); await ensure("b"); await ensure("b") + expect(h.toasts.length).toBe(2) + }) + test("a reuse after a refusal clears the record: refusal → reused → same refusal speaks again", async () => { + let onPath: string | null = null + let status: { status: string } = { status: "disabled" } + const h = install([], () => PINNED42, { spawned: undefined }) + syncInternals.which = () => onPath + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("engine-missing") // ours+down → retry → revive? no: which null → refuse-unreachable → engine-missing + onPath = "/usr/local/bin/datamate"; status = { status: "connected" }; h.spawnedNow = PINNED42 + expect((await ensure("s1")).kind).toBe("reused") + onPath = null; status = { status: "disabled" }; h.spawnedNow = undefined + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.filter((t) => t.title.includes("unavailable")).length).toBe(2) + }) + }) + + describe("a disable landing between the undo's read and the restore's write", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(() => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-restore-")), "altimate-code.json") + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function stageRestore(initial: ExistingEntry | null) { + let current: CachedBinding | null = binding + const statuses: H["statusQueue"] = initial ? [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }] : [{}, { datamate: { status: "connected" } }] + const h = install(statuses, () => null, { realPersist: true }) + delete syncInternals.persistRestore // REAL restore + syncInternals.projectConfigPath = async () => file + syncInternals.resolveBinding = async () => current + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + return e + } + let pe = 0 + let armed = false + let landed = false + syncInternals.projectEntry = async () => { + pe += 1 + const e = (await diskEntry()) ?? null + if (pe === 2) armed = true // the undo's own read has just completed + return e + } + // binding moves during tools() → post-install guard → undo + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + Filesystem.readText = async (p: string) => { + if (armed && !landed && p === file) { + landed = true + // the user disables OUR entry after the undo read it and before the restore writes + const now = (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry + writeFileSync(file, JSON.stringify({ mcp: { datamate: { ...now, enabled: false } } }, null, 2)) + } + return originalReadText(p) + } + return { h, landed: () => landed } + } + + test("a restore does not overwrite a disable with the entry it replaced", async () => { + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + const { h, landed } = stageRestore({ type: "local", command: ["datamate", "start-stdio"], enabled: true }) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RT:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after?.enabled, "the restore wrote the enabled previous entry over the user's disable").toBe(false) + }) + + test("a restore does not delete a node the user has disabled", async () => { + writeFileSync(file, "{}\n") + const { h, landed } = stageRestore(null) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RU:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after, "the restore deleted the node the user had just disabled").toBeDefined() + expect(after?.enabled).toBe(false) + }) + }) +}) From 7c48aafd94334c8a3cfe95d3ff5cc3db4e22a7dd Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 13:49:55 +0800 Subject: [PATCH 56/67] fix(workspace): ask the running engine, and undo only your own work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 2: one P1 and two P2s, and they are two rules rather than three patches. The floor was asked of the CONFIGURED command while attribution was asked of both the config and the running engine. A config edit can change the command while the existing client stays connected, so the two can carry the same pin and be different binaries — and a freshly configured 0.7 command would then authorise reuse of a running pre-0.7 engine, which does not lock its pin and can drift to another workspace while we report this one. The pin and the floor are one mechanism; they are now asked of the same thing. The undo assumed that whatever it found was its own. It is not: the MCP route and the IDE's reload both call `MCP.add` outside this flow's serialization, and an IDE or the user can rewrite the file. So an unconditional removal could close the engine another caller had just installed, leaving it disconnected with its tools gone, and an unconditional restore could roll back a new command or URL written after our pin. Both halves now check that what is there is still what this attach put there — a disable was already honoured, and any other edit is equally not ours to undo. The undo harness modelled a project file that never received the write, so its stub returned the pre-install entry at undo time. It mirrors production now; a fixture that under-models the write cannot see whether the undo checks what it is undoing. Each fix is proven by reverting it alone, including the runtime-version case, which nothing had staged: a runtime binary differing from the configured one under the same pin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 53 ++++++++++++---- .../src/altimate/workspace/engine-types.ts | 9 +++ .../altimate/workspace/engine-sync.test.ts | 63 +++++++++++++++++++ .../workspace/undo-and-teardown.test.ts | 9 ++- 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index effb201877..e0b7586d4d 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -69,6 +69,7 @@ import { installWouldHelp, isUrlEntry, pinnedWorkspace, + sameCommand, ENGINE_BINARY, INSTALL_HINT, MIN_ENGINE_VERSION, @@ -603,19 +604,33 @@ async function run(sessionID: string): Promise { * Restoring the merged value writes a copy of a global entry into the project, * which is a permanent override shadowing every later global change — undoing * a write is only correct if it restores what that write replaced. */ - const undoInstall = async (projectBefore: ExistingEntry | null): Promise<"restored" | "failed"> => { - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not remove the superseded engine", { err: String(err) }) - }) - // "Restore what the write replaced" stops being right the moment the user - // edits the thing we wrote. Between our install and this undo there is a - // whole engine boot, and a disable landing in that window lands on OUR - // entry — so restoring the pre-install state deletes the edit they just - // made, and the next turn, seeing no entry at all, spawns and re-enables. - // Round 4 arriving through the undo path. + const undoInstall = async ( + projectBefore: ExistingEntry | null, + installed: LocalMcpConfig, + ): Promise<"restored" | "failed"> => { + // An undo may only undo its OWN work, and both halves are checked because + // either can be replaced between the install and the undo: the MCP route and + // the IDE's reload both call `MCP.add` outside this flow's serialization, + // and an IDE or the user may rewrite the file. Removing or restoring blindly + // destroys someone else's work while believing it is tidying up after + // itself. + const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + if (runningNow && !sameCommand(runningNow, installed as unknown as ExistingEntry)) { + log.info("not removing the engine; something else replaced it since we installed", { workspaceId }) + } else { + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not remove the superseded engine", { err: String(err) }) + }) + } + // "Restore what the write replaced" stops being right the moment anything + // edits the thing we wrote. Between the install and this undo there is a + // whole engine boot: a disable landing in that window lands on OUR entry, + // and so does a new command or URL from an IDE. Restoring the pre-install + // state discards that edit, and the next turn — finding no entry, or ours — + // spawns over it. // // The same rule as the guard, applied to the undo's own write: no mutation - // on a stale world. Read at undo time, and never undo a disable. + // on a stale world. Read at undo time, and restore only what is still ours. let now: ExistingEntry | null = null try { now = await projectEntry(configPath) @@ -638,6 +653,12 @@ async function run(sessionID: string): Promise { const keep = projectBefore ? ({ ...projectBefore, enabled: false } as ExistingEntry) : now return await persistRestore(DATAMATE_KEY, keep, configPath) } + if (now && !sameCommand(now, installed as unknown as ExistingEntry)) { + // Rewritten while we held it — a different command, or a URL where we + // wrote a command. That edit is newer than our pin and not ours to undo. + log.info("not restoring; the entry was rewritten since we installed", { workspaceId }) + return "restored" + } return await persistRestore(DATAMATE_KEY, projectBefore, configPath) } @@ -871,9 +892,15 @@ async function run(sessionID: string): Promise { // toasted every single turn while the rejected client stayed registered and // serving: the advice-versus-registration split this module exists to close. // Read as unreadable, it is detached and refused once, and the memo holds. + // The RUNNING engine, not the configured one. A config edit can change the + // command while the existing client stays connected, so the two can carry + // the same pin and be different binaries — and a newly configured 0.7 + // command would then authorise reuse of a still-running pre-0.7 engine, + // which does not lock its pin and can drift to another workspace. The pin + // and the floor are one mechanism, so both are asked of the same thing. let found: string | null try { - found = await engineVersionOf(entry) + found = await engineVersionOf(inspection.runtime ?? entry) } catch (err) { log.warn("could not probe the entry's engine version; treating it as unreadable", { workspaceId, @@ -1104,7 +1131,7 @@ async function run(sessionID: string): Promise { const undoNow = async (): Promise => { if (!installed || undone) return undone = true - const restored = await undoInstall(projectBefore).catch((err) => { + const restored = await undoInstall(projectBefore, cfg).catch((err) => { log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) return "failed" as const }) diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index a2b49a6fd8..c6d8f5fc7c 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -192,6 +192,15 @@ export function describeMissing(missing: string[]): string { return ` Declared but not available: ${shown}${more}.` } +/** Do these two entries name the same command? + * + * The identity an undo needs: is what is here still what I put here. Compared on + * argv rather than by reference, because the value that comes back from disk or + * from MCP is a different object carrying the same meaning. */ +export function sameCommand(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { + return commandArgv(a ?? null).join(" ") === commandArgv(b ?? null).join(" ") +} + /** Is this engine version usable at all? * * The single definition of "unusable" for this module. An unreadable version is diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 24b9d444cb..b92c82e05f 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2616,3 +2616,66 @@ describe("INVARIANT — identity and paths are resolved once", () => { ]) }) }) + +describe("an undo only undoes its own work", () => { + test("a config rewritten to a new command while we held it is not rolled back", async () => { + // A disable is not the only edit that can land in the boot window: an IDE + // writing a new command or URL is newer than our pin, and rolling it back + // discards a change the user made deliberately. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // An IDE rewrites the entry to its own transport, and the binding moves. + projectNow = { type: "remote", url: "http://localhost:7801/sse", enabled: true } + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "rolled back over an edit that was not ours").toHaveLength(0) + }) + + test("a client replaced by another caller while we held it is not closed", async () => { + // The MCP route and the IDE's reload both call `MCP.add` outside this + // flow's serialization. Removing unconditionally closes whatever is there — + // which, after such a replacement, is the engine someone else just asked + // for, left disconnected with its tools gone. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // Someone else replaces the client, then the binding moves. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] } as never + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.removes, "closed a client another caller had just installed").toHaveLength(0) + }) +}) + +describe("INVARIANT — the floor is asked of the engine that is running", () => { + test("a newly configured modern command does not vouch for a still-running old engine", async () => { + // A config edit can change the command while the existing client stays + // connected, so the two can carry the same pin and be different binaries. + // Probing the CONFIGURED one then lets a fresh 0.7 command authorise reuse + // of a running pre-0.7 engine — which does not lock its pin, and can drift + // to another workspace while we report this one. The pin and the floor are + // one mechanism, so both are asked of the same thing. + const h = install({ + existing: { type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin.startsWith("/old") ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + // What is actually running is the OLD binary, same pin. + h.spawnedNow = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } as never + const outcome = await ensure("s1") + expect(outcome.kind, "reused a pre-floor engine on the strength of a newer configured command").not.toBe("reused") + expect(h.removes, "left the pre-floor engine registered").toContain("datamate") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts index df201bf2bc..d05f39e8ee 100644 --- a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts +++ b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts @@ -49,7 +49,14 @@ describe("every exit gives back what it took", () => { const last = h.persisted[h.persisted.length - 1] return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null } - syncInternals.projectEntry = async () => opts.projectEntry ?? null + // Mirrors production: once this attach has persisted, the project file holds + // OUR entry — which is what the undo reads to decide whether what is there + // is still its own work. A stub that always returns the pre-install value + // models a file that never received the write. + syncInternals.projectEntry = async () => { + const last = h.persisted[h.persisted.length - 1] + return last ? ({ ...last.cfg } as unknown as ExistingEntry) : (opts.projectEntry ?? null) + } syncInternals.notify = async (t) => { h.toasts.push(t) } syncInternals.toolsChanged = async () => {} syncInternals.persistRestore = async (_n, prev) => { h.restores.push(prev ?? null) } From 69b782499d0101b0f5fc077933990bad8c911c30 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 13:54:38 +0800 Subject: [PATCH 57/67] fix(workspace): the memo asks the running engine too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit applied "anything asking about the engine that is RUNNING asks the runtime record" to the fresh-attach path and not to the memo path beside it — which is the path every turn after the first takes. So the same defect survived where it matters most: a memo attached to a running pre-floor engine, then a config edit to a floor-clearing command under the same pin. The plan agrees (the pins match), the command has changed so the floor is re-probed — and the probe reads the CONFIGURED binary, clears the floor, records that as validated, and the running pre-floor engine keeps serving for the rest of the session behind a memo that looks valid. A pre-floor engine does not lock its pin, which is the drift this check exists to exclude. The floor is now asked of the running engine here too, and the re-probe key carries both commands rather than the config's alone: a divergence between them is precisely the case that needs re-probing, so it cannot be the case that gets skipped. Two comments in the same block: the docblock still opened "Fails OPEN", which the code below it contradicts, and it narrated what an earlier version of the function did. Both replaced by what the function guarantees now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 46 +++++++++---------- .../altimate/workspace/engine-sync.test.ts | 31 +++++++++++++ 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e0b7586d4d..7a8895b2b7 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -1336,17 +1336,11 @@ function wasServing(outcome: Outcome | undefined): boolean { /** Is the memoised success still true? * - * Validated by the SAME reader and the SAME decision as a fresh attach, because - * this was a second copy of the intent/attribution/floor logic in a different - * order — it read status before config, the reverse of what the reader - * documents — and it is the common path, taken on every turn after the first. - * A second implementation of a decision is a second place for the decision to - * be wrong, and this one was: it never consulted intent at all, so a memo - * outlived a disable for the life of the session. + * Validated by the SAME reader and the SAME decision as a fresh attach. This is + * the common path — every turn after the first takes it — and a second + * implementation of the decision would be a second place for it to be wrong. * - * "Still valid" is defined as the plan saying reuse. Nothing else. - * - * Fails OPEN: a read that throws must not invalidate a good attach. */ + * "Still valid" is the plan saying reuse. Nothing else. */ async function memoStillValid(workspaceId: string, record?: SessionAttach): Promise { try { const inspection = await inspectEntry() @@ -1362,13 +1356,20 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom } // The FLOOR is what makes the pin trustworthy, since engines below it do not - // lock it. Re-probed only when the command CHANGES, because probing spawns a - // process and this runs every turn. The residual is narrow and worth naming: - // a binary swapped in place under an unchanged command is not caught until - // the next session. - const command = commandArgv(inspection.entry).join(" ") + // lock it — and like the pin, it is a question about the engine that is + // RUNNING. Probing the configured command instead lets a newly configured + // modern binary vouch for a running pre-floor one under the same pin, and + // record that as validated for the rest of the session. + // + // Re-probed when either command changes, because probing spawns a process + // and this runs every turn. A divergence between the two IS the case that + // needs re-probing, so the key carries both. The residual is narrow and + // worth naming: a binary swapped in place under an unchanged command is not + // caught until the next session. + const running = inspection.runtime ?? inspection.entry + const command = `${commandArgv(running).join(" ")}|${commandArgv(inspection.entry).join(" ")}` if (record && record.validated === command) return true - const found = await engineVersionOf(inspection.entry) + const found = await engineVersionOf(running) if (!clearsFloor(found)) { log.info("cached attach no longer clears the version floor; re-attaching", { workspaceId, found }) return false @@ -1378,13 +1379,12 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom if (record) record.validated = command return true } catch (err) { - // Fails CLOSED, and the earlier comment claiming otherwise was wrong about - // the cost. Returning true serves a memo whose world could not be confirmed - // — a disabled entry or a moved pin rides a transient probe error, on the - // path taken by every turn after the first. Returning false does not discard - // anything: it routes back through `run()`, which re-inspects under the - // per-project lock and either attaches or refuses through the single exit, - // with no mutation. A failed read is never an answer. + // Fails CLOSED. Returning true would serve a memo whose world could not be + // confirmed — a disabled entry or a moved pin riding a transient probe + // error, on the path every turn after the first takes. Returning false + // discards nothing: it routes back through `run()`, which re-inspects under + // the per-project lock and either attaches or refuses through the single + // exit, with no mutation. A failed read is never an answer. log.warn("could not confirm the cached attach; re-deciding rather than serving it", { err: String(err) }) return false } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index b92c82e05f..16eb17effb 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2679,3 +2679,34 @@ describe("INVARIANT — the floor is asked of the engine that is running", () => expect(h.removes, "left the pre-floor engine registered").toContain("datamate") }) }) + +describe("INVARIANT — a memo is validated against the engine that is running", () => { + test("editing the config to a modern binary does not validate a running pre-floor engine", async () => { + // The same question as the fresh path, on the path every later turn takes. + // A memo attached to a running pre-floor engine, then a config edit to a + // floor-clearing command under the same pin: probing the CONFIG command + // clears the floor, records it as validated, and the running pre-floor + // engine — which does not lock its pin — keeps serving for the session. + const h = install({ + existing: { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + version: (bin) => (bin.startsWith("/old") ? "0.7.0" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + h.spawnedNow = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } as never + expect((await ensure("s1")).kind).toBe("reused") + + // The running engine is now known to be pre-floor, and the config is edited + // to a modern binary under the same pin. + syncInternals.versionOf = async (bin: string) => (bin.startsWith("/old") ? "0.6.5" : "0.7.0") + syncInternals.existingEntry = async () => + ({ type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"], enabled: true }) as never + const second = await ensure("s1") + expect(second.kind, "served a memo for a running pre-floor engine").not.toBe("reused") + expect(h.removes, "left the pre-floor engine registered and serving").toContain("datamate") + }) +}) From e4c8be46c576190169e2d3294c8fa3cfc3996c92 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 13:58:55 +0800 Subject: [PATCH 58/67] refactor(workspace): name every question, and verify identity in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings in a row have been the same shape: a rule applied at one site and not at the site beside it. This commit answers the shape rather than the sites, and folds in round 3's two findings, which are both instances of it. Two named accessors replace field reads. `runningEngine(inspection)` answers "the engine that is running, else the one configured"; `configuredEntry` answers "the entry the user configured, whatever may be running". No site reads `inspection.entry` or `inspection.runtime` bare. Naming one and not the other would leave the other implicit, and implicitness at the point of use is the condition this class grows in — the field access whose meaning has to be inferred from what happens to surround it. One place verifies identity before destroying. Round 2 added that check to the install's undo; round 3 found the rejection teardown beside it without one, so a replacement installed between judging a client and closing it was closed instead — leaving the engine someone else had just asked for disconnected, with its tools and credentials gone from the turn. Every teardown now goes through `removeIfOurs`, which also owns the binding check, because ordering two guards across two functions is how one of them ends up on the wrong side of the other. Identity first, binding last, so the binding read stays the last await before the mutation — the adjacency invariant caught that ordering when I got it wrong here. Identity now means the whole transport. Comparing argv alone read an edit to `environment`, `cwd` or `timeout` as "still the entry I wrote", so the undo reverted a deliberate change while believing it was reverting its own write. The shape test asserts what no behavioural test can: the fallback expression appears exactly once, both accessors exist, and no bare field read survives. It is weaker than the rest of this suite on purpose — the defect is not a wrong answer at a site, it is a second site existing at all, and a test cannot exercise a site that does not exist yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 112 +++++++++++++----- .../src/altimate/workspace/engine-types.ts | 28 ++++- .../altimate/workspace/engine-sync.test.ts | 81 +++++++++++++ 3 files changed, 185 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 7a8895b2b7..f5165348d8 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -69,7 +69,7 @@ import { installWouldHelp, isUrlEntry, pinnedWorkspace, - sameCommand, + sameEntry, ENGINE_BINARY, INSTALL_HINT, MIN_ENGINE_VERSION, @@ -166,6 +166,39 @@ export type Inspection = { * describe the same moment. Passing them as two arguments left it to each * caller to pair them correctly, and "the caller remembers" is the property * this whole rewrite is trying to stop relying on. */ +/** The engine that is RUNNING, else the one configured. + * + * Every question about the running engine — its version, its pin, how it is + * described to a user — goes through here, because the answer is not always the + * config: a config edit can change the command while the existing client stays + * connected, so the two can carry the same pin and be different binaries. + * + * It is one function rather than an expression at each call site so that there + * is no second place to write it. The same question was asked correctly in one + * site and incorrectly in the site beside it twice, and both times the second + * site was found by someone reading the two together rather than by the person + * fixing the first. A shared expression is a trap of exactly that shape. + * + * The fallback matters: with no runtime record, nothing of ours is running and + * the configured entry is the only evidence there is. */ +export function runningEngine(inspection: Inspection): ExistingEntry | null { + return inspection.runtime ?? inspection.entry +} + +/** The entry the user CONFIGURED, whatever may be running. + * + * The mirror of `runningEngine`, and it exists for the same reason: so that + * every read is a named question rather than a field access whose meaning has + * to be inferred from its surroundings. Naming only one of the two would leave + * the other implicit, which is the condition this class of defect grows in. + * + * Use this where the question really is about configuration — what the user + * asked for, what a pin declares, what a message should describe — and + * `runningEngine` where it is about the process that is actually up. */ +export function configuredEntry(inspection: Inspection): ExistingEntry | null { + return inspection.entry +} + async function inspectEntry(): Promise { const entry = await existingEntry(DATAMATE_KEY) const client = mcp() @@ -175,7 +208,8 @@ async function inspectEntry(): Promise { } export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { - const { entry, observed } = inspection + const entry = configuredEntry(inspection) + const { observed } = inspection // 1. INTENT. Outranks everything, including whether anything is observed at // all. `{ "datamate": { "enabled": false } }` with no `type` is the upstream @@ -211,11 +245,13 @@ export function planForEntry(inspection: Inspection, workspaceId: string, retrie // a vote. A config entry that names this workspace while MCP is serving a // process started from a different one is the silent case: every check agrees, // and the tools, and the credentials, belong to somewhere else. - const runtimePin = inspection.runtime ? pinnedWorkspace(inspection.runtime) : null - if (inspection.runtime && runtimePin !== workspaceId) { + const running = runningEngine(inspection) + const runtimeKnown = running !== entry + const runtimePin = runtimeKnown ? pinnedWorkspace(running) : null + if (runtimeKnown && runtimePin !== workspaceId) { return { act: "replace-unattributable", - entry: describeEntry(inspection.runtime), + entry: describeEntry(running), pinnedTo: runtimePin, } } @@ -568,6 +604,37 @@ async function run(sessionID: string): Promise { * connected, and the turn's `resolveTools` would hand the model exactly the * tools we just decided it must not have. It also closes the client `MCP.add` * would otherwise overwrite without closing, which orphans a second engine. */ + /** Close the client, but only if it is still the one we judged. + * + * Every destructive act verifies identity first, and it does so HERE so there + * is no second place to remember it. The MCP route and the IDE's reload both + * call `MCP.add` outside this flow's serialization, so between judging a + * client and closing it, someone else's replacement can take its place — and + * closing that leaves the engine they just asked for disconnected, with its + * tools and credentials gone from the turn. */ + const removeIfOurs = async ( + judged: ExistingEntry | null, + why: Record, + bindingDependent = false, + ): Promise => { + // Identity FIRST, binding LAST, so the binding read stays the last await + // before the mutation. Both checks live here rather than one here and one at + // the call site, because ordering two guards across two functions is how one + // of them ends up on the wrong side of the other. + const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + if (runningNow && judged && !sameEntry(runningNow, judged)) { + log.info("not detaching; something else replaced this client since we judged it", { workspaceId, ...why }) + return + } + if (bindingDependent && !(await stillCurrent())) { + log.info("skipping teardown; the binding changed while this attach was deciding", { workspaceId, ...why }) + return + } + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) + }) + } + const detachRejected = async (why: Record, bindingDependent = true): Promise => { // The guard exists to stop us destroying something that may legitimately // belong to the NEW binding. That applies to exactly one of the three @@ -585,13 +652,7 @@ async function run(sessionID: string): Promise { // Binding-DEPENDENT, and the only case the guard is for: // - a pre-existing entry we did not create and judged unattributable. If // the binding moved, that entry may be exactly what the new one wants. - if (bindingDependent && !(await stillCurrent())) { - log.info("skipping teardown; the binding changed while this attach was deciding", { workspaceId, ...why }) - return - } - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) - }) + await removeIfOurs(runningEngine(inspection), why, bindingDependent) } /** Abandon an install without trace. * @@ -614,14 +675,7 @@ async function run(sessionID: string): Promise { // and an IDE or the user may rewrite the file. Removing or restoring blindly // destroys someone else's work while believing it is tidying up after // itself. - const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined - if (runningNow && !sameCommand(runningNow, installed as unknown as ExistingEntry)) { - log.info("not removing the engine; something else replaced it since we installed", { workspaceId }) - } else { - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not remove the superseded engine", { err: String(err) }) - }) - } + await removeIfOurs(installed as unknown as ExistingEntry, { reason: "undoing our install" }) // "Restore what the write replaced" stops being right the moment anything // edits the thing we wrote. Between the install and this undo there is a // whole engine boot: a disable landing in that window lands on OUR entry, @@ -653,7 +707,7 @@ async function run(sessionID: string): Promise { const keep = projectBefore ? ({ ...projectBefore, enabled: false } as ExistingEntry) : now return await persistRestore(DATAMATE_KEY, keep, configPath) } - if (now && !sameCommand(now, installed as unknown as ExistingEntry)) { + if (now && !sameEntry(now, installed as unknown as ExistingEntry)) { // Rewritten while we held it — a different command, or a URL where we // wrote a command. That edit is newer than our pin and not ours to undo. log.info("not restoring; the entry was rewritten since we installed", { workspaceId }) @@ -759,7 +813,7 @@ async function run(sessionID: string): Promise { // `add` is none of those: it writes no config and starts exactly what it is // handed. Reviving becomes the same operation as spawning, which is the // real win — the retry stops being a special path with special rules. - const revive: LocalMcpConfig = { type: "local", command: commandArgv(inspection.entry), enabled: true } + const revive: LocalMcpConfig = { type: "local", command: commandArgv(configuredEntry(inspection)), enabled: true } // The whole world, not just the binding: this starts a process, and a // disable that landed since the inspection forbids starting it just as // surely as it forbids writing config. The plan was derived from a snapshot @@ -788,13 +842,13 @@ async function run(sessionID: string): Promise { } catch (err) { if (revived) { log.info("undoing the revive we started, since we cannot decide about it", { workspaceId }) - await client.remove(DATAMATE_KEY).catch(() => undefined) + await removeIfOurs(revive as unknown as ExistingEntry, { reason: "undoing our revive" }) } throw err } plan = planForEntry(inspection, workspaceId, true) } - const entry = inspection.entry + const entry = configuredEntry(inspection) if (plan.act === "honour-disable") { // The user turned this entry off deliberately. Do NOT call `MCP.connect` to @@ -900,7 +954,7 @@ async function run(sessionID: string): Promise { // and the floor are one mechanism, so both are asked of the same thing. let found: string | null try { - found = await engineVersionOf(inspection.runtime ?? entry) + found = await engineVersionOf(runningEngine(inspection)) } catch (err) { log.warn("could not probe the entry's engine version; treating it as unreadable", { workspaceId, @@ -940,9 +994,7 @@ async function run(sessionID: string): Promise { log.info("binding changed while reusing; detaching rather than answering for the old workspace", { workspaceId, }) - await client.remove(DATAMATE_KEY).catch((err) => { - log.warn("could not detach the superseded engine", { err: String(err) }) - }) + await removeIfOurs(runningEngine(inspection), { reason: "superseded while reusing" }) return { kind: "superseded" } } clearAnnouncement(sessionID) @@ -1366,8 +1418,8 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom // needs re-probing, so the key carries both. The residual is narrow and // worth naming: a binary swapped in place under an unchanged command is not // caught until the next session. - const running = inspection.runtime ?? inspection.entry - const command = `${commandArgv(running).join(" ")}|${commandArgv(inspection.entry).join(" ")}` + const running = runningEngine(inspection) + const command = `${commandArgv(running).join(" ")}|${commandArgv(configuredEntry(inspection)).join(" ")}` if (record && record.validated === command) return true const found = await engineVersionOf(running) if (!clearsFloor(found)) { diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index c6d8f5fc7c..6532ee51eb 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -192,13 +192,29 @@ export function describeMissing(missing: string[]): string { return ` Declared but not available: ${shown}${more}.` } -/** Do these two entries name the same command? +/** Are these two entries the same entry? * - * The identity an undo needs: is what is here still what I put here. Compared on - * argv rather than by reference, because the value that comes back from disk or - * from MCP is a different object carrying the same meaning. */ -export function sameCommand(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { - return commandArgv(a ?? null).join(" ") === commandArgv(b ?? null).join(" ") + * The identity a destructive act needs: is what is here still what I put here. + * Compared by value rather than by reference, because what comes back from disk + * or from MCP is a different object carrying the same meaning — and across + * everything that changes the process it describes, not argv alone. */ +export function sameEntry(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { + const shape = (e: ExistingEntry | null | undefined) => { + const raw = (e ?? {}) as Record + return JSON.stringify({ + type: raw.type ?? null, + url: raw.url ?? null, + argv: commandArgv((e ?? null) as ExistingEntry | null), + // Everything else that changes the process this entry describes. Comparing + // argv alone treats an edit to the environment, the working directory or + // the timeout as "unchanged", so an undo rolls it back believing it is + // reverting its own write. + environment: raw.environment ?? null, + cwd: raw.cwd ?? null, + timeout: raw.timeout ?? null, + }) + } + return shape(a) === shape(b) } /** Is this engine version usable at all? diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 16eb17effb..8dda496944 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -24,6 +24,7 @@ import { installWouldHelp, planForEntry, clearsFloor, + runningEngine, type LocalMcpConfig, type Outcome, } from "../../../src/altimate/workspace/engine-sync" @@ -2710,3 +2711,83 @@ describe("INVARIANT — a memo is validated against the engine that is running", expect(h.removes, "left the pre-floor engine registered and serving").toContain("datamate") }) }) + +describe("INVARIANT — there is one place that answers 'the engine that is running'", () => { + // Not a behaviour test. The same question was asked correctly at one site and + // incorrectly at the site beside it twice over, and both times the second site + // was found by someone reading the two together — not by the person fixing the + // first. A shared EXPRESSION invites that; a shared FUNCTION does not, because + // there is no second place to write it. + // + // So this asserts the shape rather than an outcome: the fallback expression + // appears once, inside the accessor, and every other site calls it. + test("the runtime-or-config fallback is written exactly once, in the accessor", async () => { + const { readFileSync } = await import("node:fs") + const source = readFileSync( + new URL("../../../src/altimate/workspace/engine-sync.ts", import.meta.url).pathname, + "utf8", + ) + const occurrences = source.split("\n").filter((l) => /inspection\.runtime\s*\?\?/.test(l)) + expect( + occurrences.length, + `the runtime-or-config fallback is written ${occurrences.length} times; it belongs only in runningEngine()`, + ).toBe(1) + expect( + source.includes("export function runningEngine(inspection: Inspection)"), + "the accessor every running-engine question goes through is missing", + ).toBe(true) + expect( + source.includes("export function configuredEntry(inspection: Inspection)"), + "the mirror accessor is missing, which leaves the other question unnamed", + ).toBe(true) + + // And no site reads either field bare. Naming only one of the two questions + // would leave the other implicit, which is the condition this class of + // defect grows in — a field access whose meaning has to be inferred from + // what happens to surround it. + const bare = source + .split("\n") + .map((l, i) => [i + 1, l] as const) + .filter(([, l]) => /inspection\.(entry|runtime)\b/.test(l)) + .filter(([, l]) => !/return inspection\.runtime \?\? inspection\.entry|return inspection\.entry/.test(l)) + expect( + bare.map(([n, l]) => `${n}: ${l.trim()}`), + "these read the inspection's fields directly instead of asking a named question", + ).toEqual([]) + }) + + test("the accessor prefers what is running and falls back to what is configured", () => { + const configured = { type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"] } + const running = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } + expect(runningEngine({ entry: configured, observed: undefined, runtime: running })).toBe(running) + // Nothing of ours running: the configured entry is the only evidence there is. + expect(runningEngine({ entry: configured, observed: undefined, runtime: undefined })).toBe(configured) + }) +}) + +describe("INVARIANT — identity covers everything that changes the process", () => { + test("an edit to the environment under unchanged argv is not rolled back", async () => { + // `environment`, `cwd` and `timeout` all change the process an entry + // describes. Comparing argv alone reads such an edit as "still the entry I + // wrote", so the undo reverts it while believing it is reverting its own + // write — the same wrongness as rolling back a changed command, arriving + // through a field the comparison did not look at. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // Same argv as ours, different environment — a deliberate edit. + projectNow = { + ...(cfg as unknown as ExistingEntry), + environment: { DATAMATE_LOG: "debug" }, + } as unknown as ExistingEntry + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "rolled back an environment edit it had not made").toHaveLength(0) + }) +}) From 3c6c3a6534b6f2848b10e7db6be45d440c0c791d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 14:14:44 +0800 Subject: [PATCH 59/67] fix(workspace): a stale plan is not acted on, and the probe runs where the engine runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 4, plus the identity-field exhaustiveness prepared earlier. The pre-write guard checked intent and the binding but not WHICH entry the plan was derived from. An IDE replacing that entry with a different enabled command while the probes run therefore left the plan describing something that was no longer there — and acting on it overwrote a newer entry and could displace the client it had just started. A disable is one way the entry can change; it is not the only one. The guard now compares against the entry the plan came from, before the write only: after the write what is on disk is our own, so there is no plan to compare, and a third-party rewrite landing later is answered by the undo, which already refuses to roll back an entry that is no longer ours. The version probe ran in this process's environment rather than the one the entry would be spawned in. A bare `datamate` under a custom `environment.PATH` resolves to a different binary than our PATH does, so a modern binary we happen to have could approve the pre-floor engine the entry actually selects — and a pre-floor engine does not lock its pin, which is the drift the floor exists to exclude. A relative command with a configured `cwd` was resolved from the wrong directory for the same reason. The probe takes both. Identity is now exhaustive at compile time. `IDENTITY_FIELDS` is keyed by `ExistingEntry`, so adding a field there fails the build until someone lists it or excludes it deliberately — verified by adding one and watching it fail by name. That closes the limit named in the previous commit, where a field the TYPE gained could pass unnoticed; `enabled` stays excluded, because a disabled entry is still the same entry. The entry type gains `environment`, `cwd` and `timeout`, which identity was already reading through casts. One test changed contract rather than expectation: an entry appearing between the config read and the write is no longer spawned over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-probes.ts | 19 ++++- .../src/altimate/workspace/engine-seams.ts | 2 +- .../src/altimate/workspace/engine-sync.ts | 35 +++++++-- .../src/altimate/workspace/engine-types.ts | 54 ++++++++++--- .../altimate/workspace/engine-sync.test.ts | 77 +++++++++++++++++++ .../workspace/mutation-guards.test.ts | 11 ++- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index fe52be5e2b..1c06bb4552 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -45,8 +45,8 @@ export function which(cmd: string): string | null { /** `datamate --version` — the engine inlines its real package version here, * unlike its MCP `serverInfo`, which is a hard-coded placeholder. A version * string proves output, not identity; it is a compatibility floor only. */ -export function versionOf(bin: string): Promise { - if (syncInternals.versionOf) return syncInternals.versionOf(bin) +export function versionOf(bin: string, spawn?: { environment?: Record; cwd?: string }): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin, spawn) return new Promise((resolve) => { // cross-spawn, not execFile. An npm-installed engine on Windows is resolved // by `which` to a `.cmd` shim (it honours PATHEXT), and Node cannot execute @@ -61,7 +61,18 @@ export function versionOf(bin: string): Promise { resolve(value) } try { - const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) + // In the environment the entry would be SPAWNED in, not this process's. + // A bare `datamate` under a custom `environment.PATH` resolves to a + // different binary than the parent PATH does, so probing here would let a + // modern binary on our PATH approve the pre-floor engine the entry + // actually selects — and a relative command with a configured `cwd` would + // be probed from the wrong directory entirely. + const child = launch(bin, ["--version"], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + ...(spawn?.environment ? { env: { ...process.env, ...spawn.environment } } : {}), + ...(spawn?.cwd ? { cwd: spawn.cwd } : {}), + }) let out = "" child.stdout?.on("data", (chunk) => { out += String(chunk) @@ -189,5 +200,5 @@ export async function notify(toast: Toast): Promise { export async function engineVersionOf(entry: ExistingEntry | null): Promise { const bin = commandArgv(entry)[0] const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null - return direct ? await versionOf(direct) : null + return direct ? await versionOf(direct, { environment: entry?.environment, cwd: entry?.cwd }) : null } diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 63a713f696..2bb7a5df88 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -15,7 +15,7 @@ export const log = Log.create({ service: "workspace-engine" }) export const syncInternals: { resolveBinding?: () => Promise which?: (cmd: string) => string | null - versionOf?: (bin: string) => Promise + versionOf?: (bin: string, spawn?: { environment?: Record; cwd?: string }) => Promise mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index f5165348d8..c0564e44eb 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -96,6 +96,7 @@ import { serializeAttach, trackedChainsForTests, attachChains } from "./engine-c // moved to. export { attributableEngine, + sameEntry, clearsFloor, compareVersions, engineToolKeys, @@ -514,7 +515,18 @@ async function run(sessionID: string): Promise { * text it modifies, which is as close as that can be got, but one read and one * write to one file is not atomic — see the note on `persist`. This guard * narrows the window; it does not close it. */ - const worldUnchanged = async (): Promise<"ok" | "moved" | "disabled" | "unreadable"> => { + const worldUnchanged = async ( + // The entry the PLAN was derived from, when the caller is about to act on + // that plan. Given only before a write: acting on a plan whose entry has + // been replaced overwrites a newer entry and can displace the client it + // started. + // + // Deliberately NOT given after the write. By then the entry on disk is our + // own, so there is nothing to compare a plan against — and a third-party + // rewrite landing after our write is a different question, answered by the + // undo, which already refuses to roll back an entry that is no longer ours. + expected?: ExistingEntry | null, + ): Promise<"ok" | "moved" | "disabled" | "unreadable" | "replaced"> => { // Intent FIRST, binding LAST — reversed again, and this is the considered // order rather than the obvious one. // @@ -551,6 +563,17 @@ async function run(sessionID: string): Promise { log.info("intent changed while deciding; not writing over a disable", { workspaceId }) return "disabled" } + // The plan was derived from a particular entry. If that entry has been + // REPLACED — a different enabled command, or a URL where a command was — + // the plan describes something that is no longer there, and acting on it + // overwrites a newer entry and can displace the client it started. A + // disable is one way the entry can change; it is not the only one. + if (expected !== undefined && !sameEntry(entryNow, expected)) { + log.info("the entry was replaced while deciding; re-deciding rather than acting on a stale plan", { + workspaceId, + }) + return "replaced" + } if (!(await stillCurrent())) return "moved" return "ok" } @@ -675,7 +698,7 @@ async function run(sessionID: string): Promise { // and an IDE or the user may rewrite the file. Removing or restoring blindly // destroys someone else's work while believing it is tidying up after // itself. - await removeIfOurs(installed as unknown as ExistingEntry, { reason: "undoing our install" }) + await removeIfOurs(installed, { reason: "undoing our install" }) // "Restore what the write replaced" stops being right the moment anything // edits the thing we wrote. Between the install and this undo there is a // whole engine boot: a disable landing in that window lands on OUR entry, @@ -707,7 +730,7 @@ async function run(sessionID: string): Promise { const keep = projectBefore ? ({ ...projectBefore, enabled: false } as ExistingEntry) : now return await persistRestore(DATAMATE_KEY, keep, configPath) } - if (now && !sameEntry(now, installed as unknown as ExistingEntry)) { + if (now && !sameEntry(now, installed)) { // Rewritten while we held it — a different command, or a URL where we // wrote a command. That edit is newer than our pin and not ours to undo. log.info("not restoring; the entry was rewritten since we installed", { workspaceId }) @@ -818,7 +841,7 @@ async function run(sessionID: string): Promise { // disable that landed since the inspection forbids starting it just as // surely as it forbids writing config. The plan was derived from a snapshot // taken before a status read; re-confirm both halves before acting on it. - const beforeRevive = await worldUnchanged() + const beforeRevive = await worldUnchanged(configuredEntry(inspection)) if (beforeRevive === "disabled") return await refuseDisabled() if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeRevive !== "ok") return { kind: "superseded" } @@ -842,7 +865,7 @@ async function run(sessionID: string): Promise { } catch (err) { if (revived) { log.info("undoing the revive we started, since we cannot decide about it", { workspaceId }) - await removeIfOurs(revive as unknown as ExistingEntry, { reason: "undoing our revive" }) + await removeIfOurs(revive, { reason: "undoing our revive" }) } throw err } @@ -1135,7 +1158,7 @@ async function run(sessionID: string): Promise { variant: "error", }) } - const beforeInstall = await worldUnchanged() + const beforeInstall = await worldUnchanged(configuredEntry(inspection)) if (beforeInstall === "disabled") return await refuseDisabled() if (beforeInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeInstall !== "ok") { diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 6532ee51eb..30c1fb351c 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -35,7 +35,16 @@ export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolea * `command: string[]` argv, or the `{ command, args }` split an IDE writes and * `datamate-transport` normalises. Read defensively — this is merged config * written by other clients. */ -export type ExistingEntry = { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } +export type ExistingEntry = { + type?: string + url?: string + command?: string[] | string + args?: string[] + environment?: Record + cwd?: string + timeout?: number + enabled?: boolean +} export type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } @@ -198,21 +207,42 @@ export function describeMissing(missing: string[]): string { * Compared by value rather than by reference, because what comes back from disk * or from MCP is a different object carrying the same meaning — and across * everything that changes the process it describes, not argv alone. */ +/** Every field of an entry that identity depends on. + * + * Keyed by the type so the compiler asks the question: add a field to + * `ExistingEntry` and this fails to build until someone either lists it here or + * adds it to the exclusion, which makes ignoring it a decision rather than a + * default. A field the comparison silently forgets makes two different entries + * compare equal, and a teardown or an undo then acts on something that is not + * its own while believing it is. + * + * `enabled` is excluded on purpose: a disabled entry is still the same entry. + * Intent is decided above the comparison, which keeps a disable rather than + * rolling it back; folding it in here would make a disable read as somebody + * else's entry and take the wrong branch for the right-sounding reason. */ +const IDENTITY_FIELDS: Record, true> = { + type: true, + url: true, + command: true, + args: true, + environment: true, + cwd: true, + timeout: true, +} + export function sameEntry(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { const shape = (e: ExistingEntry | null | undefined) => { const raw = (e ?? {}) as Record - return JSON.stringify({ - type: raw.type ?? null, - url: raw.url ?? null, + const parts: Record = { + // `command` and `args` are compared as the argv they produce, since the + // same invocation can be spelled either way. argv: commandArgv((e ?? null) as ExistingEntry | null), - // Everything else that changes the process this entry describes. Comparing - // argv alone treats an edit to the environment, the working directory or - // the timeout as "unchanged", so an undo rolls it back believing it is - // reverting its own write. - environment: raw.environment ?? null, - cwd: raw.cwd ?? null, - timeout: raw.timeout ?? null, - }) + } + for (const field of Object.keys(IDENTITY_FIELDS)) { + if (field === "command" || field === "args") continue + parts[field] = raw[field] ?? null + } + return JSON.stringify(parts) } return shape(a) === shape(b) } diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 8dda496944..414a2644d1 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -25,6 +25,7 @@ import { planForEntry, clearsFloor, runningEngine, + sameEntry, type LocalMcpConfig, type Outcome, } from "../../../src/altimate/workspace/engine-sync" @@ -2791,3 +2792,79 @@ describe("INVARIANT — identity covers everything that changes the process", () expect(h.restores, "rolled back an environment edit it had not made").toHaveLength(0) }) }) + +describe("INVARIANT — identity normalises every field that changes the process", () => { + // `removeIfOurs` and the undo both decide from `sameEntry`. A field it does + // not look at makes two different entries compare equal, and the caller then + // destroys or restores something that is not its own while believing it is. + // + // Each case changes exactly one field and asserts the comparison notices. + const base = { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { A: "1" }, + cwd: "/work", + timeout: 5000, + } as unknown as ExistingEntry + + const variants: Array<[string, ExistingEntry]> = [ + ["command", { ...base, command: ["datamate", "start-stdio", "--datamate", "9"] } as ExistingEntry], + ["environment", { ...(base as object), environment: { A: "2" } } as unknown as ExistingEntry], + ["cwd", { ...(base as object), cwd: "/elsewhere" } as unknown as ExistingEntry], + ["timeout", { ...(base as object), timeout: 9000 } as unknown as ExistingEntry], + ["type/url", { type: "remote", url: "http://localhost:7801/sse" } as unknown as ExistingEntry], + ] + + for (const [field, changed] of variants) { + test(`a change to ${field} is not the same entry`, () => { + expect(sameEntry(base, changed), `${field} is invisible to the comparison`).toBe(false) + }) + } + + test("the same entry from a different source still compares equal", () => { + // What comes back from disk or from MCP is a different object with the same + // meaning, so the comparison is by value. + expect(sameEntry(base, JSON.parse(JSON.stringify(base)) as ExistingEntry)).toBe(true) + }) + + test("intent is not identity: enabled is deliberately excluded", () => { + // A disabled entry is still the same entry. Intent is handled by the branch + // above the comparison, which keeps the disable rather than rolling it back; + // folding it in here would make a disable read as "someone else's entry" and + // take a different path for the same reason. + expect(sameEntry(base, { ...(base as object), enabled: false } as unknown as ExistingEntry)).toBe(true) + }) +}) + +describe("INVARIANT — the version probe runs where the engine would run", () => { + test("the entry's own environment and working directory reach the probe", async () => { + // A bare `datamate` under a custom `environment.PATH` resolves to a + // different binary than this process's PATH does. Probing here rather than + // there lets a modern binary we happen to have approve the pre-floor engine + // the entry actually selects — and that engine does not lock its pin. A + // relative command with a configured `cwd` is resolved from the wrong + // directory for the same reason. + const seen: Array<{ environment?: Record; cwd?: string } | undefined> = [] + const h = install({ + existing: { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/opt/pinned/bin" }, + cwd: "/work/project", + enabled: true, + } as never, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async (_bin: string, spawn?: { environment?: Record; cwd?: string }) => { + seen.push(spawn) + return "0.7.0" + } + await ensure("s1") + expect(seen[0]?.environment, "probed with this process's environment, not the entry's").toEqual({ + PATH: "/opt/pinned/bin", + }) + expect(seen[0]?.cwd, "probed from the wrong directory").toBe("/work/project") + void h + }) +}) diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts index 50c406100e..33026f063d 100644 --- a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -553,7 +553,11 @@ describe("a mutation is never made on a world that has moved", () => { expect(["reused", "attached"]).toContain((await ensure("s1")).kind) }) - test("an entry an IDE adds after the config read is seen, not spawned over persists over it, unreported", async () => { + test("an entry an IDE adds after the config read is not spawned over", async () => { + // The plan was derived from "there is no entry here". If one appears + // before the write, acting on that plan persists over it and can displace + // the client it started — so the attach abandons and the next turn + // re-decides against the entry that is actually there. let onDisk: ExistingEntry | null = null const h = install([{}, { datamate: { status: "connected" } }], () => onDisk) const realStatus = syncInternals.mcp!.status @@ -562,9 +566,8 @@ describe("a mutation is never made on a world that has moved", () => { return realStatus() } const outcome = await ensure("s1") - expect(outcome.kind).toBe("attached") - expect((outcome as { replaced?: string }).replaced).toBeUndefined() - expect(h.persisted).toHaveLength(1) + expect(outcome.kind).toBe("superseded") + expect(h.persisted, "wrote over an entry that appeared after the plan was made").toHaveLength(0) expect(h.removes).toEqual([]) }) }) From cde3f61d89138d4026ac92663cbfb41deee8e2ce Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 14:36:21 +0800 Subject: [PATCH 60/67] feat(workspace): say once when another datamate is serving alongside us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datamate_manager` can add standalone `datamate-` entries pointing at the hosted endpoint, and those keep their own clients. This flow owns one key and never touches theirs, so after a successful attach the model can hold two tool sets at once — ours for the bound workspace, and another datamate's under its own credentials. That is the misattribution this branch exists to prevent, arriving from a direction the flow does not control. Surfaced, not filtered. The user added those servers deliberately, and removing a server from their own turns is a product decision rather than this module's to make; the ambiguity is made visible instead of silently resolved either way. One signal per session per SET, so a stable configuration says it once and a change says it again. It is a second signal beside the attach toast rather than folded into it, because two different things happened — the rule is one signal per event, not one element per screen. It costs no extra read: the inspection already reads every server's status, so it keeps that record and the neighbours are answered from the same moment as everything else the inspection decided from. Named residual, and asserted as such: the note rides the flow's decisions, so a set that changes while a memo stays valid is surfaced at the next re-decision rather than the moment it changes. The alternative is a read on every turn for a warning, which is the wrong trade. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 63 +++++++++++++++- .../altimate/workspace/engine-sync.test.ts | 71 +++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index c0564e44eb..7bf4d48a17 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -74,6 +74,7 @@ import { INSTALL_HINT, MIN_ENGINE_VERSION, type ExistingEntry, + type McpStatus, type LocalMcpConfig, type Outcome, type Toast, @@ -154,6 +155,12 @@ export type Inspection = { * module agree with itself while the live client served another workspace's * data under this workspace's name. */ runtime?: ExistingEntry | undefined + /** Every server MCP knows about, not only ours. + * + * Kept from the status read the inspection already performs, so a question + * about the neighbours costs no second call — and is answered from the same + * moment as everything else this inspection decided from. */ + all?: McpStatus } /** Config and runtime, read together, in the one correct order. @@ -203,9 +210,9 @@ export function configuredEntry(inspection: Inspection): ExistingEntry | null { async function inspectEntry(): Promise { const entry = await existingEntry(DATAMATE_KEY) const client = mcp() - const observed = (await client.status())[DATAMATE_KEY] + const all = await client.status() const runtime = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined - return { entry, observed, runtime } + return { entry, observed: all[DATAMATE_KEY], runtime, all } } export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { @@ -578,6 +585,50 @@ async function run(sessionID: string): Promise { return "ok" } + /** Say once that a hosted datamate is also serving this session. + * + * `datamate_manager` can add standalone `datamate-` entries pointing at + * the hosted endpoint, and those keep their own clients. This flow owns one + * key and does not touch theirs, so after a successful attach the model can + * hold both tool sets at once — ours for the bound workspace, and another + * datamate's under its own credentials. + * + * Not filtered: the user added those servers deliberately, and removing a + * server from their turns is not this module's decision to make. Surfaced + * instead, so the ambiguity is visible rather than silent. + * + * One signal per session per SET, so a stable configuration says it once and a + * change says it again. Separate from the attach toast on purpose: two + * different things happened, so there are two signals — the rule is one signal + * per event, not one element per screen. */ + const noteHostedNeighbours = async (outcome: Outcome): Promise => { + if (!attributableEngine(outcome)) return + try { + const hosted = Object.entries(inspection.all ?? {}) + .filter( + ([key, value]) => + key !== DATAMATE_KEY && key.startsWith(`${DATAMATE_KEY}-`) && value?.status === "connected", + ) + .map(([key]) => key) + .sort() + if (hosted.length === 0) return + const signature = hosted.join(",") + const record = sessions.get(sessionID) + if (record?.announcedHosted === signature) return + if (record) record.announcedHosted = signature + await notify({ + title: "Another datamate is also connected", + message: + `Workspace "${binding.datamateName}" is attached, and ${hosted.join(", ")} ` + + `${hosted.length === 1 ? "is" : "are"} also connected. Tools from ${hosted.length === 1 ? "it" : "them"} ` + + `serve a different datamate, under its own credentials — check which you are using before running one.`, + variant: "warning", + }) + } catch (err) { + log.warn("could not check for other connected datamate servers", { workspaceId, err: String(err) }) + } + } + /** The refusal an unreadable configuration earns. * * One failure, one label, wherever it lands: the reader propagates rather than @@ -1028,11 +1079,13 @@ async function run(sessionID: string): Promise { declared: declaredKeys?.keys.length, missing, }) - return { + const reused: Outcome = { kind: "reused", available, ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), } + await noteHostedNeighbours(reused) + return reused } // Pinned to us, but below the floor or unreadable. Prefer a newer engine on @@ -1338,6 +1391,7 @@ async function run(sessionID: string): Promise { err: String(err), }) } + await noteHostedNeighbours(outcome) return outcome } catch (err) { // Undo first, then decide how to report. A throw that lands after a re-link @@ -1381,6 +1435,8 @@ type SessionAttach = { key?: string /** The last verdict this session was told about — see `verdictSignature`. */ announced?: string + /** The set of hosted datamate servers this session has been told about. */ + announcedHosted?: string task: Promise waitTimedOut?: boolean outcome?: Outcome @@ -1557,6 +1613,7 @@ export function ensure(sessionID: string): Promise { // per call, so state that is not copied is state that is silently rebuilt — // and rebuilding this one turns "say it once" back into "say it every turn". announced: previous?.announced, + announcedHosted: previous?.announcedHosted, } as SessionAttach // The whole task, not just the attach. `attachKey`, the memo re-validation and // the serialization chain all run BEFORE the attach's own catch, so a throw in diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 414a2644d1..c36994c5e0 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -2868,3 +2868,74 @@ describe("INVARIANT — the version probe runs where the engine would run", () = void h }) }) + +describe("INVARIANT — a hosted datamate serving alongside us is surfaced, once", () => { + const hostedConnected = { + datamate: { status: "connected" }, + "datamate-acme": { status: "connected" }, + } + + function withHosted(extra: Record = {}) { + const statuses = [{ ...hostedConnected, ...extra }, { ...hostedConnected, ...extra }, { ...hostedConnected, ...extra }] + return install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: statuses as never, + tools: { datamate_dbt_build_model: 1 }, + }) + } + + test("three turns with the same hosted set produce one signal", async () => { + const h = withHosted() + for (const _ of [1, 2, 3]) await ensure("s1") + const notes = h.toasts.filter((t) => t.title.includes("Another datamate")) + expect(notes.length, `told the user ${notes.length} times about an unchanged set`).toBe(1) + expect(notes[0]!.message).toContain("datamate-acme") + }) + + test("a change to the hosted set is announced again", async () => { + const h = withHosted() + await ensure("s1") + // A second standalone server appears, and the memo is no longer valid — so + // this turn re-decides and sees the new set. + syncInternals.mcp!.status = async () => + ({ ...hostedConnected, "datamate-beta": { status: "connected" } }) as never + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + await ensure("s1") + expect(h.toasts.filter((t) => t.title.includes("Another datamate")).length).toBe(2) + expect(h.toasts.filter((t) => t.title.includes("Another datamate"))[1]!.message).toContain("datamate-beta") + }) + + test("the note is attached to a decision, so a memoised turn does not repeat or refresh it", async () => { + // Named rather than hidden: the signal rides the flow's decisions, so a set + // that changes while a memo stays valid is surfaced at the next + // re-decision, not the moment it changes. That is the cost of not adding a + // read to every turn for a warning. + const h = withHosted() + await ensure("s1") + syncInternals.mcp!.status = async () => + ({ ...hostedConnected, "datamate-beta": { status: "connected" } }) as never + await ensure("s1") // memo still valid — no re-decision, so no new note + expect(h.toasts.filter((t) => t.title.includes("Another datamate")).length).toBe(1) + }) + + test("no hosted server means no signal at all", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(h.toasts.filter((t) => t.title.includes("Another datamate"))).toHaveLength(0) + }) + + test("it is a second signal, not a rewrite of the attach toast", async () => { + // Two different things happened — an attach, and an ambiguity about whose + // tools the model is holding — so the user gets two signals. The rule is one + // signal per event, not one element per screen. + const h = withHosted() + await ensure("s1") + const titles = h.toasts.map((t) => t.title) + expect(titles.some((t) => t.includes("Another datamate")), "the ambiguity went unmentioned").toBe(true) + expect(titles.length, "the two events did not produce two signals").toBeGreaterThanOrEqual(2) + }) +}) From f5040b3fceee902f319ccae2030506ca6f789085 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 14:56:06 +0800 Subject: [PATCH 61/67] fix(workspace): commit only what we installed, and revive the whole transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 5: one P1, one P2, both mine from the round before. After the install, the guard asked whether the world had moved but not whether what is SERVING is still ours. The status and tool reads are two awaits, and both the MCP route and the IDE's reload call `MCP.add` outside this flow's serialization — so a replacement landing there was committed and reported as the bound workspace's engine, and its tools and credentials reached the model. The runtime is now compared against what we installed before committing. Scoped to the runtime half deliberately. What is on disk after our write is our own, and an edit landing on it afterwards belongs to the undo, which already refuses to roll back an entry that is no longer ours; comparing it here would ask the same question twice and answer it in two places. The revive rebuilt the entry as bare argv, dropping `environment`, `cwd` and `timeout`. Those are what the configured engine was meant to run under — a custom PATH may be the only place its binary exists, and a relative command resolves from `cwd` — so the retry restarted a different process than the one that failed. It carries the whole transport now, which is the same lesson identity comparison learned two commits ago, applied to reconstruction. Three test harnesses were preferring their starting entry forever, modelling a config file that never received the write. That is invisible until something asks whether what is installed is still its own, and then it answers "no" for every successful attach — which is why I scoped this check away in the previous round instead of fixing the fixtures. Fixed here. The fourth harness is left alone: it models the file live and inferring from what was persisted would shadow the rewrites it exists to exercise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y9X4vNjkwQ3vTiJ8w1kKR6 --- .../src/altimate/workspace/engine-sync.ts | 37 ++++++++++++- .../src/altimate/workspace/engine-types.ts | 9 +++- .../altimate/workspace/config-on-disk.test.ts | 4 ++ .../altimate/workspace/engine-sync.test.ts | 54 +++++++++++++++++++ .../workspace/mutation-guards.test.ts | 9 +++- .../altimate/workspace/seam-contract.test.ts | 7 +-- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 7bf4d48a17..a1115bb3ba 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -887,12 +887,29 @@ async function run(sessionID: string): Promise { // `add` is none of those: it writes no config and starts exactly what it is // handed. Reviving becomes the same operation as spawning, which is the // real win — the retry stops being a special path with special rules. - const revive: LocalMcpConfig = { type: "local", command: commandArgv(configuredEntry(inspection)), enabled: true } + // The whole transport, not just the argv. `environment`, `cwd` and + // `timeout` are what the configured engine was meant to run under — a + // custom PATH may be the only place its binary exists, and a relative + // command resolves from `cwd`. Reviving with a flattened shadow of the + // entry restarts a different process than the one that failed. + const configured = configuredEntry(inspection) + const revive: LocalMcpConfig = { + type: "local", + command: commandArgv(configured), + enabled: true, + ...(configured?.environment ? { environment: configured.environment } : {}), + ...(configured?.cwd ? { cwd: configured.cwd } : {}), + ...(configured?.timeout !== undefined ? { timeout: configured.timeout } : {}), + } // The whole world, not just the binding: this starts a process, and a // disable that landed since the inspection forbids starting it just as // surely as it forbids writing config. The plan was derived from a snapshot // taken before a status read; re-confirm both halves before acting on it. - const beforeRevive = await worldUnchanged(configuredEntry(inspection)) + // No expected entry here. The revive re-inspects and re-plans immediately + // afterwards, so a change landing between the inspection and the restart is + // absorbed by that — this guard only has to answer intent and the binding. + // The spawn path is different: it acts on its plan with no further look. + const beforeRevive = await worldUnchanged() if (beforeRevive === "disabled") return await refuseDisabled() if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") if (beforeRevive !== "ok") return { kind: "superseded" } @@ -1332,7 +1349,23 @@ async function run(sessionID: string): Promise { // Late rather than early on purpose: the check is only meaningful at the // last moment before we announce and answer, because everything before that // is still revocable. The undo itself now belongs to the region. + // After the write, "has the world moved" becomes "is what is SERVING still + // mine". The runtime is the half that matters here: an IDE reload or the MCP + // route can replace the client during the status and tool awaits, and + // committing without asking would report the bound workspace as served by a + // client that is unpinned or pinned elsewhere — whose tools and credentials + // then reach the model. + // + // The config half is deliberately not compared here. What is on disk after + // our write is our own, and an edit landing on it afterwards belongs to the + // undo, which already refuses to roll back an entry that is no longer ours. const afterInstall = await worldUnchanged() + const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + if (afterInstall === "ok" && runningNow && !sameEntry(runningNow, cfg)) { + log.info("the client we installed was replaced before we could report it; undoing", { workspaceId }) + await undoNow() + return { kind: "superseded" } + } if (afterInstall !== "ok") { log.info("the world changed before the attach could be reported; undoing what we installed", { workspaceId, diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 30c1fb351c..5fdf8831a9 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -29,7 +29,14 @@ export type Outcome = | { kind: "entry-disabled" } | { kind: "superseded" } -export type LocalMcpConfig = { type: "local"; command: string[]; enabled: boolean } +export type LocalMcpConfig = { + type: "local" + command: string[] + enabled: boolean + environment?: Record + cwd?: string + timeout?: number +} /** A configured MCP entry, in either shape it can reach us: opencode's own * `command: string[]` argv, or the `{ command, args }` split an IDE writes and diff --git a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts index 1e04601001..9840392281 100644 --- a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts +++ b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts @@ -59,6 +59,10 @@ describe("the write checks the text it is about to modify", () => { } } syncInternals.existingEntry = async () => { + // `entry()` IS the file here — these tests model it live, updating it from + // their own `persist` override and from the edits they stage. Nothing is + // inferred from what was persisted, because inferring would shadow the + // very rewrites this file exists to exercise. const e = entry() h.reads.push(e?.enabled) return e diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index c36994c5e0..2e4448202b 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -93,6 +93,13 @@ function install(opts: { h.persisted.push({ name, cfg }) } syncInternals.existingEntry = async () => { + // Mirrors production: once this attach has written, the entry on disk is + // OURS, and later reads see that rather than the starting value. Preferring + // the starting entry forever models a file that never received the write, + // which is invisible until something asks whether what is installed is still + // its own — and then it answers "no" for every successful attach. + const written = h.persisted[h.persisted.length - 1] + if (written) return { ...(written.cfg as unknown as ExistingEntry) } if (opts.existing !== undefined) return opts.existing // Production persists the pinned entry before adding it, so a later read // sees it. Without this the harness under-reports and a legitimate memo @@ -2939,3 +2946,50 @@ describe("INVARIANT — a hosted datamate serving alongside us is surfaced, once expect(titles.length, "the two events did not produce two signals").toBeGreaterThanOrEqual(2) }) }) + +describe("INVARIANT — what is committed is what we installed", () => { + test("a client replaced during the post-install awaits is not reported as ours", async () => { + // The status and tool reads are two awaits, and the MCP route and the IDE's + // reload both call `MCP.add` outside this flow's serialization. A + // replacement landing there is what serves the turn — so committing without + // asking reports the bound workspace as served by a client that may be + // unpinned or pinned elsewhere, whose tools and credentials then reach the + // model. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + // Someone else replaces the client while we are listing tools. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevTools() + } + const outcome = await ensure("s1") + expect(outcome.kind, "reported a replacement as the bound workspace's engine").toBe("superseded") + }) + + test("a revive restarts the entry with its own environment and working directory", async () => { + // `environment`, `cwd` and `timeout` are what the configured engine was + // meant to run under — a custom PATH may be the only place its binary + // exists. Reviving with a flattened argv restarts a different process than + // the one that failed. + const h = install({ + existing: { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/opt/pinned/bin" }, + cwd: "/work/project", + timeout: 12_000, + enabled: true, + } as never, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const revived = h.added[0]?.cfg as unknown as Record + expect(revived?.environment, "revived with this process's environment").toEqual({ PATH: "/opt/pinned/bin" }) + expect(revived?.cwd, "revived from the wrong directory").toBe("/work/project") + expect(revived?.timeout, "revived with the default timeout").toBe(12_000) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts index 33026f063d..f3d0f39159 100644 --- a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -64,9 +64,14 @@ describe("the world check sits adjacent to every mutation", () => { syncInternals.projectEntry = async () => (seam("projectEntry"), null) syncInternals.existingEntry = async () => { seam("existingEntry") - if (opts.existing !== undefined) return opts.existing + // Mirrors production: once this attach has written, the entry on disk is + // OURS, and later reads see that rather than the pre-install value. A stub + // that keeps returning the starting entry models a file that never + // received the write — which is invisible to a test until something starts + // asking whether what is installed is still its own. const last = h.persisted[h.persisted.length - 1] - return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + if (last) return { ...(last.cfg as unknown as ExistingEntry) } + return opts.existing !== undefined ? opts.existing : null } syncInternals.notify = async (toast) => { seam("notify") diff --git a/packages/opencode/test/altimate/workspace/seam-contract.test.ts b/packages/opencode/test/altimate/workspace/seam-contract.test.ts index 421d22d83c..c59c53cf16 100644 --- a/packages/opencode/test/altimate/workspace/seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/seam-contract.test.ts @@ -70,9 +70,10 @@ function install(opts: { h.persisted.push({ name, cfg }) } syncInternals.existingEntry = async () => { - if (opts.existing !== undefined) return opts.existing - const last = h.persisted[h.persisted.length - 1] - return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + // Mirrors production: after this attach writes, the entry on disk is ours. + const written = h.persisted[h.persisted.length - 1] + if (written) return { ...(written.cfg as unknown as ExistingEntry) } + return opts.existing !== undefined ? opts.existing : null } syncInternals.notify = async (toast) => { h.toasts.push(toast) From 37cabe5ed84a08cd6f49e7f8842a22a71bba55a3 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 15:09:59 +0800 Subject: [PATCH 62/67] fix(workspace): every answer that names an engine asks the same two questions first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reused` is an answer, like `attached`: it names the engine this turn is served by. The install region already re-asked, after its awaits, whether the world was unchanged (binding and intent) and whether what is serving is still the client it installed. The reuse answer re-asked only the binding, after the same kind of awaits (the tool listing, the allowlist lookup) that the MCP route and the IDE's reload can land an `MCP.add` inside — so a replacement was answered `reused` for the bound workspace, and a disable landing there served the turn from an engine the user had just switched off. One helper, `confirmServing`, now asks both questions for both answers. It returns a verdict rather than an outcome so that each caller runs its own teardown first — the install region undoes what it installed, the reuse answer detaches what it judged — and only then announces; a replacement is never ours to detach. The missing-declared-tools warning on the reuse path moves below those questions: nothing that names an engine, answer or toast, is emitted before they are asked. Three existing tests asserted the older contract (a disable landing between the inspection's two reads was served for a turn and repaired on the next; the retry path made three intent reads); they now assert the property. Reverting the source fails five named tests; disabling only the serving check fails the replacement test at both callers. --- .../src/altimate/workspace/engine-sync.ts | 96 ++++++++++++------- .../altimate/workspace/config-on-disk.test.ts | 8 +- .../altimate/workspace/engine-sync.test.ts | 46 +++++++++ .../workspace/mutation-guards.test.ts | 8 +- .../altimate/workspace/seam-contract.test.ts | 10 +- 5 files changed, 126 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index a1115bb3ba..e300773103 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -665,6 +665,29 @@ async function run(sessionID: string): Promise { false, ) + /** The two questions every answer that NAMES an engine asks before it is + * given — `attached` after an install, `reused` after a lookup. + * + * Is the world unchanged (binding AND intent), and is what is serving still + * the engine that was judged? Both answers follow awaits (the handshake, the + * tool listing, the allowlist lookup) that a re-link, a disable, or a + * replacement via `MCP.add` from the route or the IDE's reload can land + * inside; an answer given without asking names the bound workspace over + * whatever is serving now. One definition, so the two callers cannot drift. + * + * Returns a verdict rather than an outcome: teardown belongs to the caller + * (the install region undoes what it installed, the reuse answer detaches + * what it judged), and it must run BEFORE the refusal is announced. */ + const confirmServing = async ( + judged: ExistingEntry | null, + ): Promise<"ok" | "disabled" | "unreadable" | "moved" | "replaced"> => { + const world = await worldUnchanged() + if (world !== "ok") return world === "replaced" ? "moved" : world + const servingNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + if (servingNow && judged && !sameEntry(servingNow, judged)) return "replaced" + return "ok" + } + /** Stop serving an entry we have judged untrustworthy for this workspace. * * Runtime-only (`MCP.remove`): closes the client and drops it from the tool @@ -1063,21 +1086,26 @@ async function run(sessionID: string): Promise { const declaredKeys = await declaredBounded(workspaceId) const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] const available = present.size - if (declaredKeys && missing.length > 0) { - await notify({ - title: `Workspace "${binding.datamateName}" is missing declared tools`, - message: - `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + - describeMissing(missing), - variant: "warning", - }) - } // Returning `reused` ASSERTS that the connected engine serves the current // binding — and the lookup above can have waited. Every mutation already // revalidates; so must this, because the caller acts on the answer just as // surely. A re-link inside that await would otherwise hand this turn the // previous workspace's tools, and its credentials, under the new binding. - if (!(await stillCurrent())) { + // + // The tool and allowlist reads above are two awaits; `confirmServing` + // asks the two questions every named answer asks after them. + const verdict = await confirmServing(runningEngine(inspection)) + if (verdict === "replaced") { + // The replacement is someone else's; it is not ours to detach, and the + // next decision judges it on its own merits. + log.info("the engine we judged was replaced during the reuse lookup; not answering for it", { + workspaceId, + }) + return { kind: "superseded" } + } + if (verdict === "disabled") return await refuseDisabled() + if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") + if (verdict === "moved") { // Detach, do not merely decline. The caller runs `resolveTools` whatever // this returns, so leaving the old client registered hands that turn the // previous workspace's tools and credentials anyway — the outcome is @@ -1088,6 +1116,19 @@ async function run(sessionID: string): Promise { await removeIfOurs(runningEngine(inspection), { reason: "superseded while reusing" }) return { kind: "superseded" } } + // The gap is reported only for the engine this turn is actually answered + // with. Announcing it before the questions above would warn about an + // engine that is then refused or found replaced — a second signal for a + // refusal, and a warning about a client that is not the one serving. + if (declaredKeys && missing.length > 0) { + await notify({ + title: `Workspace "${binding.datamateName}" is missing declared tools`, + message: + `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + + describeMissing(missing), + variant: "warning", + }) + } clearAnnouncement(sessionID) log.info("reusing existing engine entry", { workspaceId, @@ -1350,32 +1391,23 @@ async function run(sessionID: string): Promise { // last moment before we announce and answer, because everything before that // is still revocable. The undo itself now belongs to the region. // After the write, "has the world moved" becomes "is what is SERVING still - // mine". The runtime is the half that matters here: an IDE reload or the MCP - // route can replace the client during the status and tool awaits, and - // committing without asking would report the bound workspace as served by a - // client that is unpinned or pinned elsewhere — whose tools and credentials - // then reach the model. - // - // The config half is deliberately not compared here. What is on disk after - // our write is our own, and an edit landing on it afterwards belongs to the - // undo, which already refuses to roll back an entry that is no longer ours. - const afterInstall = await worldUnchanged() - const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined - if (afterInstall === "ok" && runningNow && !sameEntry(runningNow, cfg)) { - log.info("the client we installed was replaced before we could report it; undoing", { workspaceId }) - await undoNow() - return { kind: "superseded" } - } - if (afterInstall !== "ok") { + // mine" — `confirmServing` asks both, the same two questions the reuse + // answer asks. The config half is deliberately not compared against the + // plan here: what is on disk after our write is our own, and an edit + // landing on it afterwards belongs to the undo, which already refuses to + // roll back an entry that is no longer ours. + const verdict = await confirmServing(cfg) + if (verdict !== "ok") { log.info("the world changed before the attach could be reported; undoing what we installed", { workspaceId, - why: afterInstall, + why: verdict, }) - // Either way the install is undone by the region. A disable reports itself - // so the user learns their edit took effect, rather than a generic race. + // Either way the install is undone by the region, BEFORE anything is + // announced. A disable reports itself so the user learns their edit took + // effect, rather than a generic race. await undoNow() - if (afterInstall === "disabled") return await refuseDisabled() - if (afterInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") + if (verdict === "disabled") return await refuseDisabled() + if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") return { kind: "superseded" } } diff --git a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts index 9840392281..caeeb9c1b7 100644 --- a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts +++ b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts @@ -306,7 +306,10 @@ describe("the write checks the text it is about to modify", () => { }) describe("edits landing between the two reads of one inspection", () => { - test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + test("(a) disable after the config read, client live → honoured in the same turn, no persist", async () => { + // The inspection read the entry enabled; the disable lands before the + // status read. The reuse answer re-asks intent before naming the engine, + // so the turn is refused and the engine detached now, not a turn later. let enabled = true const h = install([{ datamate: { status: "connected" } }], () => ({ type: "local", @@ -318,9 +321,8 @@ describe("the write checks the text it is about to modify", () => { enabled = false return realStatus() } - expect((await ensure("s1")).kind).toBe("reused") - expect(h.persisted).toEqual([]) expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) expect(h.removes).toEqual(["datamate"]) }) }) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 2e4448202b..6526ab1dfa 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1268,6 +1268,52 @@ describe("an answer is revalidated before it is given", () => { expect(await ensure("s1")).toEqual({ kind: "superseded" }) expect(h.added).toHaveLength(0) }) + + test("a client replaced during the reuse lookup is not answered as ours", async () => { + // Same writers as the install region — the MCP route and the IDE's reload + // call `MCP.add` outside this flow's serialization — and the same two + // awaits (tools, allowlist) sit between judging the engine and answering + // for it. Answering `reused` for the replacement names the bound workspace + // over a client that may be pinned elsewhere; the replacement is also not + // ours to detach. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevTools() + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added, "spawned over a replacement it did not judge").toHaveLength(0) + expect(h.removes, "detached a client that was not the one it judged").toHaveLength(0) + }) + + test("a disable during the reuse lookup is honoured, not answered with reused", async () => { + // Intent outranks everything, including a reuse already decided. The tool + // and allowlist reads are awaits a disable can land inside; answering + // `reused` afterwards serves the turn from an engine the user has just + // switched off, and the memo would only notice on the following turn. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let reads = 0 + syncInternals.existingEntry = async () => { + reads += 1 + // The inspection sees the entry enabled; every read after it sees the + // disable the user wrote while the lookup was in flight. + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: reads === 1 } + } + const outcome = await ensure("s1") + expect(outcome.kind, "served a turn from an engine the user disabled").toBe("entry-disabled") + expect(h.removes, "left the disabled engine serving").toEqual(["datamate"]) + expect(h.persisted, "wrote config while honouring a disable").toHaveLength(0) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine is disabled"]) + }) }) describe("a cached success is re-probed against the floor", () => { diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts index f3d0f39159..8e01946705 100644 --- a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -524,7 +524,10 @@ describe("a mutation is never made on a world that has moved", () => { }) describe("edits landing between the two reads of one inspection, with no revive", () => { - test("(a) disable after the config read, client live → reused one turn, repaired next turn, no persist", async () => { + test("(a) disable after the config read, client live → honoured in the same turn, no persist", async () => { + // The reuse answer re-asks intent before naming the engine, so a disable + // that lands between the inspection's two reads is refused now, with the + // engine detached, rather than served for a turn and repaired on the next. let enabled = true const h = install([{ datamate: { status: "connected" } }], () => ({ type: "local", @@ -536,9 +539,8 @@ describe("a mutation is never made on a world that has moved", () => { enabled = false return realStatus() } - expect((await ensure("s1")).kind).toBe("reused") - expect(h.persisted).toEqual([]) expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) expect(h.removes).toEqual(["datamate"]) }) diff --git a/packages/opencode/test/altimate/workspace/seam-contract.test.ts b/packages/opencode/test/altimate/workspace/seam-contract.test.ts index c59c53cf16..94a7a18e62 100644 --- a/packages/opencode/test/altimate/workspace/seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/seam-contract.test.ts @@ -538,10 +538,12 @@ describe("the retry re-inspects, and never writes the memo early or twice", () = expect(outcome).toMatchObject({ kind: "reused" }) expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) expect(h.added, "the retry restarts the entry exactly once").toHaveLength(1) - // Three: the inspection, the pre-revive world check's intent read, and the - // re-inspection. The middle one is the guard confirming intent immediately - // before starting a process — a mutation, and mutations re-read. - expect(entryReads).toBe(3) + // Four: the inspection, the pre-revive world check's intent read, the + // re-inspection, and the reuse answer's own world check. The second is the + // guard confirming intent immediately before starting a process; the + // fourth confirms it again before the answer names the engine — mutations + // and named answers both re-read. + expect(entryReads).toBe(4) expect(statusReads).toBe(2) expect(reads.every((r) => r === undefined)).toBe(true) // nothing observable mid-run expect(settledOutcome("s1")).toBe(outcome) From 5de0acf1435f1646dca2cf96ac97217d7416dba5 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 15:34:23 +0800 Subject: [PATCH 63/67] fix(workspace): an answer names an engine that is there, and is true when it is given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the questions every named answer asks. A client that vanished is not a client that was replaced. `confirmServing` compared the runtime's launch record against the engine that was judged, but treated NO record as agreement — so a client removed or disconnected during the tool listing or the allowlist lookup was still answered `attached` or `reused`. The record is the only witness to "still serving"; its absence is now the verdict `gone`, handled at both callers like a replacement: the install is undone, the reuse answer is `superseded`, and nothing is detached because nothing is there. A harness that does not model the record is not asked. The attached answer was fixed before the success announcements and given after them, and the announcements are three awaits. A re-link, a disable or a replacement landing inside them left this turn holding `attached` for an engine that no longer served the bound workspace — the one residual the install region still named. Every await after a guard belongs to the guard: the world is asked once more after the last announcement, and the install is undone and answered `superseded` if it moved. The toast was true when it was shown; the answer is true when it is given; no second toast. The test that asserted the residual now asserts its closure; a read-count fixture gains the fourth intent read of the first attach. Removing the `gone` verdict fails two named tests; removing the post-announcement check fails three. --- .../src/altimate/workspace/engine-sync.ts | 40 ++++++++++--- .../altimate/workspace/engine-sync.test.ts | 56 +++++++++++++++++++ .../workspace/mutation-guards.test.ts | 25 ++++----- .../workspace/undo-and-teardown.test.ts | 5 +- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index e300773103..ee7d5ce32a 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -680,11 +680,19 @@ async function run(sessionID: string): Promise { * what it judged), and it must run BEFORE the refusal is announced. */ const confirmServing = async ( judged: ExistingEntry | null, - ): Promise<"ok" | "disabled" | "unreadable" | "moved" | "replaced"> => { + ): Promise<"ok" | "disabled" | "unreadable" | "moved" | "replaced" | "gone"> => { const world = await worldUnchanged() if (world !== "ok") return world === "replaced" ? "moved" : world - const servingNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined - if (servingNow && judged && !sameEntry(servingNow, judged)) return "replaced" + // The runtime's own record of what it launched is the only witness to + // "still serving". A record that names a different launch is a replacement; + // NO record means the client was removed or disconnected while we waited, + // and an answer naming an engine that is not there is as wrong as one naming + // the wrong engine. A harness that does not model the record is not asked. + if (client.spawned) { + const servingNow = await client.spawned(DATAMATE_KEY).catch(() => undefined) + if (!servingNow) return "gone" + if (judged && !sameEntry(servingNow, judged)) return "replaced" + } return "ok" } @@ -1095,11 +1103,13 @@ async function run(sessionID: string): Promise { // The tool and allowlist reads above are two awaits; `confirmServing` // asks the two questions every named answer asks after them. const verdict = await confirmServing(runningEngine(inspection)) - if (verdict === "replaced") { - // The replacement is someone else's; it is not ours to detach, and the - // next decision judges it on its own merits. - log.info("the engine we judged was replaced during the reuse lookup; not answering for it", { + if (verdict === "replaced" || verdict === "gone") { + // A replacement is someone else's and not ours to detach; a client that + // is gone has nothing to detach. Either way the next decision judges + // what is there on its own merits. + log.info("the engine we judged is no longer the one serving; not answering for it", { workspaceId, + verdict, }) return { kind: "superseded" } } @@ -1457,6 +1467,22 @@ async function run(sessionID: string): Promise { }) } await noteHostedNeighbours(outcome) + // Three more awaits sit between fixing the answer and giving it, and every + // await after a guard belongs to the guard's problem: a re-link, a disable + // or a replacement landing inside the announcements would otherwise leave + // this turn holding `attached` for an engine that no longer serves the + // bound workspace. The toast was true when it was shown; the answer must be + // true when it is given. Superseded is repairable, so the next turn + // re-decides for whatever is bound then. + const afterAnnouncing = await confirmServing(cfg) + if (afterAnnouncing !== "ok") { + log.info("the world changed while the attach was being announced; undoing rather than answering for it", { + workspaceId, + why: afterAnnouncing, + }) + await undoNow() + return { kind: "superseded" } + } return outcome } catch (err) { // Undo first, then decide how to report. A throw that lands after a re-link diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 6526ab1dfa..dbeb2ff279 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -1314,6 +1314,62 @@ describe("an answer is revalidated before it is given", () => { expect(h.persisted, "wrote config while honouring a disable").toHaveLength(0) expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine is disabled"]) }) + + test("a client that vanished during the reuse lookup is not answered as serving", async () => { + // Someone disconnects or removes the entry while the tool listing is in + // flight. There is nothing to detach and nothing serving; answering + // `reused` would name an engine that is not there. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = undefined + return prevTools() + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added).toHaveLength(0) + expect(h.removes).toHaveLength(0) + }) + + test("a client that vanished during the post-install awaits is not reported as attached", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = undefined + return prevTools() + } + expect((await ensure("s1")).kind, "reported an engine that is no longer there").toBe("superseded") + expect(h.restores, "left our pin on disk for a client that is gone").toHaveLength(1) + }) + + test("a re-link during the success announcements is not answered with the old workspace", async () => { + // The answer was fixed before the announcements, but it is GIVEN after + // them, and they are awaits. The toast was true when shown; the answer must + // be true when returned — so the world is asked once more after the last + // announcement, and the install is undone if it moved. No second toast. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const prevNotify = syncInternals.notify! + syncInternals.notify = async (toast) => { + await prevNotify(toast) + if (toast.title.endsWith("connected")) current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.restores).toHaveLength(1) + expect(h.toasts.filter((t) => t.title.endsWith("connected"))).toHaveLength(1) + expect(h.toasts.filter((t) => t.variant === "error")).toHaveLength(0) + }) }) describe("a cached success is re-probed against the floor", () => { diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts index 8e01946705..311e47b344 100644 --- a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -293,26 +293,25 @@ describe("the world check sits adjacent to every mutation", () => { // --------------------------------------------------------------------------- // T4 — answered after awaits that follow the final guard (announce, notify). // --------------------------------------------------------------------------- - describe("the attached answer is fixed before it is announced", () => { - test("a re-link during announceToolsChanged is answered `attached` for the old workspace", async () => { + describe("the attached answer is true when it is given, not only when it was fixed", () => { + test("a re-link during announceToolsChanged is not answered `attached` for the old workspace", async () => { const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) syncInternals.toolsChanged = async () => { h.trace.push("toolsChanged") h.current = B } const outcome = await ensure("s1") - // The answer is now fixed BEFORE the announcements rather than after them, - // so the decision no longer straddles those awaits — but a re-link landing - // inside the toast still leaves this turn holding `attached` for 42. It - // cannot be guarded without either un-saying a toast already shown or - // announcing a success we then retract. - // - // What must hold is that it does not OUTLIVE the turn: the memo is keyed to - // the workspace it was taken for, so the next turn re-decides for 99 rather - // than riding it. - expect(outcome.kind).toBe("attached") + // The answer is fixed before the announcements and GIVEN after them, and + // the announcements are awaits. The world is asked once more after the + // last of them: a re-link landing inside is undone and answered + // `superseded`, so this turn never holds `attached` for a workspace the + // project has left. The toast was true when it was shown; no second one. + expect(outcome.kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.restores).toHaveLength(1) + // Superseded is repairable: the next turn attaches for the new binding. const second = await ensure("s1") - expect(second.kind, "rode a memo taken for the workspace the project had left").not.toBe("reused") + expect(second.kind).toBe("attached") expect(h.added.at(-1)?.cfg.command, "did not re-attach for the new binding").toEqual([ "datamate", "start-stdio", diff --git a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts index d05f39e8ee..c0bf18feae 100644 --- a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts +++ b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts @@ -639,7 +639,10 @@ describe("the restore refuses on the text it edits", () => { }) test("memo validation read throws (transient) → not served, re-decided → reused; no toast", async () => { - const { h } = realReader((n) => n === 4, () => (h.added.length ? PINNED42 : null)) + // The first attach makes four intent reads (inspection, pre-install + // guard, post-install guard, post-announcement guard); the memo + // validation on the next turn is the fifth. + const { h } = realReader((n) => n === 5, () => (h.added.length ? PINNED42 : null)) const first = await ensure("s1") expect(first.kind).toBe("attached") h.statusQueue = [{ datamate: { status: "connected" } }] From bf30354f171dab97004d472ac4514931a15893b3 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 15:52:41 +0800 Subject: [PATCH 64/67] fix(workspace): ask about the running engine the way it runs, and keep asking until the answer is given Four sites where a rule stated in one place had not reached its neighbour. The reuse answer, like the attached one, is given after announcements that are awaits; it now asks the same two questions again after the last of them and settles a change the same way it would have before them. The verdict handling is one closure asked twice, so the two askings cannot drift. The version probe resolves a relative `cwd` against the instance directory, as the engine's own launch does; probed against wherever this process started, a relative command or PATH entry could name a different binary than the one the engine runs. The memo's re-probe key carries the whole launch identity of both halves, not their argv: a replacement with the same argv under a different PATH or working directory runs a different binary and is probed again. `entryIdentity` is the one definition, shared with `sameEntry`. The undo keeps an entry that was rewritten AND disabled while it was held. Projecting the disable onto what the undo replaced would have overwritten the newer transport with the old one; neither the transport nor the disable is ours. A read-count fixture gains the reuse answer's second intent read. Reverting each of the four fails a named test. --- .../src/altimate/workspace/engine-probes.ts | 10 ++- .../src/altimate/workspace/engine-seams.ts | 3 + .../src/altimate/workspace/engine-sync.ts | 72 +++++++++++------ .../src/altimate/workspace/engine-types.ts | 31 +++++--- .../altimate/workspace/engine-sync.test.ts | 79 +++++++++++++++++++ .../altimate/workspace/seam-contract.test.ts | 13 +-- 6 files changed, 163 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 1c06bb4552..214fa84617 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -3,6 +3,7 @@ // Everything that asks the outside world a question: the binary, its version, // MCP, the workspace allowlist, and the user-facing toast. Moved verbatim — the // state machine buys nothing here, and every touched line is new-bug surface. +import path from "path" import launch from "cross-spawn" import { which as whichBinary } from "@opencode-ai/core/util/which" import { MCP, ToolsChanged } from "@/mcp" @@ -200,5 +201,12 @@ export async function notify(toast: Toast): Promise { export async function engineVersionOf(entry: ExistingEntry | null): Promise { const bin = commandArgv(entry)[0] const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null - return direct ? await versionOf(direct, { environment: entry?.environment, cwd: entry?.cwd }) : null + if (!direct) return null + // Probed the way the engine is launched: MCP resolves a relative `cwd` + // against the instance directory, so the probe does too. Left relative, it + // would resolve against wherever this process happened to start, and a + // relative command or PATH entry could name a different binary there. + const base = currentDirectory() + const cwd = entry?.cwd ? (base ? path.resolve(base, entry.cwd) : entry.cwd) : undefined + return await versionOf(direct, { environment: entry?.environment, cwd }) } diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 2bb7a5df88..b44c65313e 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -16,6 +16,8 @@ export const syncInternals: { resolveBinding?: () => Promise which?: (cmd: string) => string | null versionOf?: (bin: string, spawn?: { environment?: Record; cwd?: string }) => Promise + /** The instance directory a relative launch `cwd` resolves against. */ + instanceDirectory?: () => string | null mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig) => Promise @@ -44,6 +46,7 @@ export function isEnabled(): boolean { } export function currentDirectory(): string | null { + if (syncInternals.instanceDirectory) return syncInternals.instanceDirectory() try { return Instance.directory } catch { diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index ee7d5ce32a..411b677611 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -70,6 +70,7 @@ import { isUrlEntry, pinnedWorkspace, sameEntry, + entryIdentity, ENGINE_BINARY, INSTALL_HINT, MIN_ENGINE_VERSION, @@ -98,6 +99,7 @@ import { serializeAttach, trackedChainsForTests, attachChains } from "./engine-c export { attributableEngine, sameEntry, + entryIdentity, clearsFloor, compareVersions, engineToolKeys, @@ -806,6 +808,13 @@ async function run(sessionID: string): Promise { return "failed" } if (now?.enabled === false) { + if (!sameEntry(now, installed)) { + // Rewritten AND disabled while we held it. Neither the transport nor + // the disable is ours: projecting the disable onto what we replaced + // would overwrite the newer transport with the old one. + log.info("not restoring; the entry was rewritten and disabled since we installed", { workspaceId }) + return "restored" + } log.info("the entry was disabled while we held it; keeping the disable rather than undoing it", { workspaceId, }) @@ -1102,30 +1111,35 @@ async function run(sessionID: string): Promise { // // The tool and allowlist reads above are two awaits; `confirmServing` // asks the two questions every named answer asks after them. - const verdict = await confirmServing(runningEngine(inspection)) - if (verdict === "replaced" || verdict === "gone") { - // A replacement is someone else's and not ours to detach; a client that - // is gone has nothing to detach. Either way the next decision judges - // what is there on its own merits. - log.info("the engine we judged is no longer the one serving; not answering for it", { - workspaceId, - verdict, - }) - return { kind: "superseded" } - } - if (verdict === "disabled") return await refuseDisabled() - if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") - if (verdict === "moved") { - // Detach, do not merely decline. The caller runs `resolveTools` whatever - // this returns, so leaving the old client registered hands that turn the - // previous workspace's tools and credentials anyway — the outcome is - // advice, the registration is what the model sees. + // What a non-`ok` verdict means for a reuse answer. Asked twice: once + // after the lookup awaits, and again after the announcements — which are + // awaits too, and every await after a guard belongs to the guard. + const settleReuse = async (verdict: Awaited>): Promise => { + if (verdict === "ok") return null + if (verdict === "replaced" || verdict === "gone") { + // A replacement is someone else's and not ours to detach; a client + // that is gone has nothing to detach. Either way the next decision + // judges what is there on its own merits. + log.info("the engine we judged is no longer the one serving; not answering for it", { + workspaceId, + verdict, + }) + return { kind: "superseded" } + } + if (verdict === "disabled") return await refuseDisabled() + if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") + // Moved. Detach, do not merely decline: the caller runs `resolveTools` + // whatever this returns, so leaving the old client registered hands + // that turn the previous workspace's tools and credentials anyway — the + // outcome is advice, the registration is what the model sees. log.info("binding changed while reusing; detaching rather than answering for the old workspace", { workspaceId, }) await removeIfOurs(runningEngine(inspection), { reason: "superseded while reusing" }) return { kind: "superseded" } } + const settled = await settleReuse(await confirmServing(runningEngine(inspection))) + if (settled) return settled // The gap is reported only for the engine this turn is actually answered // with. Announcing it before the questions above would warn about an // engine that is then refused or found replaced — a second signal for a @@ -1139,6 +1153,17 @@ async function run(sessionID: string): Promise { variant: "warning", }) } + const reused: Outcome = { + kind: "reused", + available, + ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), + } + await noteHostedNeighbours(reused) + // The announcements above are awaits; the answer must be true when it is + // given, not only when it was fixed. Same check, same handling, after + // the last of them. + const afterAnnouncing = await settleReuse(await confirmServing(runningEngine(inspection))) + if (afterAnnouncing) return afterAnnouncing clearAnnouncement(sessionID) log.info("reusing existing engine entry", { workspaceId, @@ -1147,12 +1172,6 @@ async function run(sessionID: string): Promise { declared: declaredKeys?.keys.length, missing, }) - const reused: Outcome = { - kind: "reused", - available, - ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), - } - await noteHostedNeighbours(reused) return reused } @@ -1589,7 +1608,10 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom // worth naming: a binary swapped in place under an unchanged command is not // caught until the next session. const running = runningEngine(inspection) - const command = `${commandArgv(running).join(" ")}|${commandArgv(configuredEntry(inspection)).join(" ")}` + // Keyed on the whole launch identity of both halves, not their argv: a + // replacement with the same argv under a different PATH or working + // directory runs a different binary, and must be probed again. + const command = `${entryIdentity(running)}|${entryIdentity(configuredEntry(inspection))}` if (record && record.validated === command) return true const found = await engineVersionOf(running) if (!clearsFloor(found)) { diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 5fdf8831a9..10c4dd0506 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -238,20 +238,25 @@ const IDENTITY_FIELDS: Record, true> = { } export function sameEntry(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { - const shape = (e: ExistingEntry | null | undefined) => { - const raw = (e ?? {}) as Record - const parts: Record = { - // `command` and `args` are compared as the argv they produce, since the - // same invocation can be spelled either way. - argv: commandArgv((e ?? null) as ExistingEntry | null), - } - for (const field of Object.keys(IDENTITY_FIELDS)) { - if (field === "command" || field === "args") continue - parts[field] = raw[field] ?? null - } - return JSON.stringify(parts) + return entryIdentity(a) === entryIdentity(b) +} + +/** The identity of the process an entry describes, as one comparable string — + * the same fields `sameEntry` compares, usable as a cache key. A cache keyed + * on argv alone accepts a replacement with the same argv under a different + * PATH or working directory, which runs a different binary. */ +export function entryIdentity(e: ExistingEntry | null | undefined): string { + const raw = (e ?? {}) as Record + const parts: Record = { + // `command` and `args` are compared as the argv they produce, since the + // same invocation can be spelled either way. + argv: commandArgv((e ?? null) as ExistingEntry | null), + } + for (const field of Object.keys(IDENTITY_FIELDS)) { + if (field === "command" || field === "args") continue + parts[field] = raw[field] ?? null } - return shape(a) === shape(b) + return JSON.stringify(parts) } /** Is this engine version usable at all? diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index dbeb2ff279..0825ad7b61 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -29,6 +29,7 @@ import { type LocalMcpConfig, type Outcome, } from "../../../src/altimate/workspace/engine-sync" +import { engineVersionOf } from "../../../src/altimate/workspace/engine-probes" import type { CachedBinding } from "../../../src/altimate/workspace/state" import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" @@ -1370,6 +1371,84 @@ describe("an answer is revalidated before it is given", () => { expect(h.toasts.filter((t) => t.title.endsWith("connected"))).toHaveLength(1) expect(h.toasts.filter((t) => t.variant === "error")).toHaveLength(0) }) + + test("a re-link during the reuse announcements is not answered with the old workspace", async () => { + // The reuse answer is fixed after the lookup and given after the + // missing-tools warning and the hosted-neighbours note, which are awaits. + // Same rule as the attached path: asked again after the last announcement. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, // dbt_compile_model is declared but missing → a warning is announced + }) + syncInternals.resolveBinding = async () => current + const prevNotify = syncInternals.notify! + syncInternals.notify = async (toast) => { + await prevNotify(toast) + if (toast.title.includes("missing declared tools")) current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("a memo is re-probed when the running launch changes under an unchanged argv", async () => { + // Identity is the whole launch — a replacement with the same argv under a + // different PATH runs a different binary, and a cache keyed on argv alone + // would accept it without asking its version. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let probes = 0 + syncInternals.versionOf = async () => ((probes += 1), "0.7.0") + await ensure("s1") // reuse: probes once + await ensure("s1") // memo validation: probes once and records the launch identity + const validated = probes + await ensure("s1") // unchanged launch: no probe + expect(probes, "re-probed an unchanged launch").toBe(validated) + h.spawnedNow = { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/somewhere/else/bin" }, + } as never + await ensure("s1") + expect(probes, "accepted a replacement with the same argv under a different PATH without probing").toBe(validated + 1) + }) + + test("the undo keeps an entry that was rewritten AND disabled while it was held", async () => { + // Neither the new transport nor the disable is ours: projecting the disable + // onto what we replaced would overwrite the newer transport with the old one. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => + h.persisted.length + ? ({ type: "local", command: ["/their/datamate", "start-stdio", "--datamate", "42"], enabled: false } as ExistingEntry) + : null + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return prevTools() + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.restores, "overwrote a transport the user rewrote while we held the entry").toHaveLength(0) + }) + + test("the version probe resolves a relative cwd the way the engine is launched", async () => { + // MCP resolves a relative `cwd` against the instance directory. Probed + // against the process's own directory instead, a relative command or PATH + // entry can name a different binary than the one the engine runs. + const seen: Array = [] + syncInternals.instanceDirectory = () => "/proj/root" + syncInternals.versionOf = async (_bin, spawn) => ((seen.push(spawn?.cwd)), "0.7.0") + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], cwd: "tools" } as ExistingEntry) + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], cwd: "/abs/tools" } as ExistingEntry) + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } as ExistingEntry) + expect(seen).toEqual(["/proj/root/tools", "/abs/tools", undefined]) + }) }) describe("a cached success is re-probed against the floor", () => { diff --git a/packages/opencode/test/altimate/workspace/seam-contract.test.ts b/packages/opencode/test/altimate/workspace/seam-contract.test.ts index 94a7a18e62..7d9a5a06db 100644 --- a/packages/opencode/test/altimate/workspace/seam-contract.test.ts +++ b/packages/opencode/test/altimate/workspace/seam-contract.test.ts @@ -538,12 +538,13 @@ describe("the retry re-inspects, and never writes the memo early or twice", () = expect(outcome).toMatchObject({ kind: "reused" }) expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) expect(h.added, "the retry restarts the entry exactly once").toHaveLength(1) - // Four: the inspection, the pre-revive world check's intent read, the - // re-inspection, and the reuse answer's own world check. The second is the - // guard confirming intent immediately before starting a process; the - // fourth confirms it again before the answer names the engine — mutations - // and named answers both re-read. - expect(entryReads).toBe(4) + // Five: the inspection, the pre-revive world check's intent read, the + // re-inspection, and the reuse answer's two world checks — one after the + // lookup awaits and one after the announcements. The second is the guard + // confirming intent immediately before starting a process; the last two + // confirm it before the answer names the engine and again when the answer + // is given — mutations and named answers both re-read. + expect(entryReads).toBe(5) expect(statusReads).toBe(2) expect(reads.every((r) => r === undefined)).toBe(true) // nothing observable mid-run expect(settledOutcome("s1")).toBe(outcome) From fc941b1a35811a5004c3c2c759a87c039800f41f Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 16:07:36 +0800 Subject: [PATCH 65/67] fix(workspace): the memo's last question is the runtime's, and a failed replacement closes only what it replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more places where the second question was asked one read too early. The memo path validated the runtime record, then read the binding one last time, then returned the memo. A client registered under the key between those two reads — the MCP route or the IDE's reload — was what `resolveTools` handed the model, under the cached attribution. The validation now records the launch identity it judged, and the memo is returned only after the record is read once more and still names it. `MCP.createAndStore` closed whatever was registered under the key when its own creation failed. Creation awaits a handshake, and nothing serializes adds to one key across callers, so a failure could close another caller's successful client and drop the record of what is actually running. It now captures the client it is replacing before creating, and on failure closes only that — if someone else registered meanwhile, theirs is left alone, status and record included. The workspace harness copies the runtime record instead of aliasing it to the config entry, as production does. Reverting either fix fails a named test. --- .../src/altimate/workspace/engine-sync.ts | 25 +++++++++++-- packages/opencode/src/mcp/index.ts | 23 ++++++++---- .../altimate/workspace/engine-sync.test.ts | 34 ++++++++++++++++-- packages/opencode/test/mcp/lifecycle.test.ts | 36 ++++++++++++++++++- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index 411b677611..bada8fcc64 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -1552,6 +1552,11 @@ type SessionAttach = { outcome?: Outcome /** The entry argv whose version we last verified against the floor. */ validated?: string + /** The launch identity of the running engine the last memo validation + * judged — set only when the runtime had a record to judge. Compared once + * more after the final binding read, so the memo is not returned for a + * client that replaced it in between. */ + judged?: string } /** Outcomes the user can repair without restarting: install the engine, update @@ -1612,6 +1617,11 @@ async function memoStillValid(workspaceId: string, record?: SessionAttach): Prom // replacement with the same argv under a different PATH or working // directory runs a different binary, and must be probed again. const command = `${entryIdentity(running)}|${entryIdentity(configuredEntry(inspection))}` + // What was judged, for the caller's last question after its final binding + // read. Only when the runtime had a record — `runningEngine` falls back to + // the configured entry when it has none, and a later read of the record + // has nothing to disagree with in that case. + if (record) record.judged = running !== configuredEntry(inspection) ? entryIdentity(running) : undefined if (record && record.validated === command) return true const found = await engineVersionOf(running) if (!clearsFloor(found)) { @@ -1750,8 +1760,19 @@ export function ensure(sessionID: string): Promise { // path lives outside `run()` and therefore never had its final check; // without one, a confirmed-valid engine for the workspace we just left is // returned as the answer for the one we just joined. - if (reusable && (await attachKeyWorkspace()) === boundTo) return previous!.task - log.info("cached attach is no longer connected; re-attaching", { sessionID }) + if (reusable && (await attachKeyWorkspace()) === boundTo) { + // The memo names an engine, so it is given only after the same two + // questions every named answer asks. The binding was just confirmed; + // the runtime record was read inside the validation, one binding read + // ago — and the MCP route or the IDE's reload can replace the client + // in that gap. Asked again, last, because what is registered now is + // what `resolveTools` will hand the model. + const servingNow = entry.judged ? await mcp().spawned?.(DATAMATE_KEY).catch(() => undefined) : undefined + if (!entry.judged || (servingNow && entryIdentity(servingNow) === entry.judged)) return previous!.task + log.info("the running engine changed after the memo was validated; re-attaching", { sessionID }) + } else { + log.info("cached attach is no longer connected; re-attaching", { sessionID }) + } } // Recomputed AFTER the awaited validation above: a re-link landing inside it // would otherwise file this fresh attach under the workspace key it started diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ef0ac95e89..6161a7e2d5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -932,21 +932,32 @@ export const layer = Layer.effect( const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) + // altimate_change start — the client this call is replacing, captured + // before creation. Creation awaits a handshake, and another caller can + // register its own client under this key meanwhile; a failure here must + // close what THIS call was replacing, not whatever is registered now. + const replacing = s.clients[name] + // altimate_change end const result = yield* create(name, mcp) - s.status[name] = result.status if (!result.mcpClient) { + // altimate_change start — a replacement that failed to come up leaves + // nothing running under this key ONLY if nobody else registered a client + // while it was coming up. If someone did, theirs is what is serving: + // leave it, its status and its launch record alone, and report this + // failure without touching them. + if (s.clients[name] !== replacing) return result.status + s.status[name] = result.status yield* closeClient(s, name) delete s.clients[name] - // altimate_change start — a replacement that failed to come up leaves - // nothing running under this key, so the record of what was running must - // go with it. `add` over a live client closes the old one here; keeping - // its record would have `spawned()` describe a closed process, which is - // the one thing this record exists not to do. + // `add` over a live client closes the old one here; keeping its record + // would have `spawned()` describe a closed process, which is the one + // thing this record exists not to do. delete s.spawned[name] // altimate_change end return result.status } + s.status[name] = result.status // altimate_change start — remember what we actually spawned, not what the // file says. diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index 0825ad7b61..e8f8350329 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -79,8 +79,12 @@ function install(opts: { // A configured entry that is already CONNECTED was bootstrapped from that // entry, which is what MCP records. A failed one has no record: production // only records a spawn when the client actually came up. - spawnedNow: ((opts.statuses?.[0]?.["datamate"]?.status === "connected" ? opts.existing : undefined) ?? - undefined) as ExistingEntry | undefined, + // A COPY, as in production: MCP's record is its own object, never the + // config entry itself. Aliasing them here would make "the runtime had a + // record" indistinguishable from "the runtime fell back to the config". + spawnedNow: (opts.statuses?.[0]?.["datamate"]?.status === "connected" && opts.existing + ? { ...opts.existing } + : undefined) as ExistingEntry | undefined, } syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) @@ -1449,6 +1453,32 @@ describe("an answer is revalidated before it is given", () => { await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } as ExistingEntry) expect(seen).toEqual(["/proj/root/tools", "/abs/tools", undefined]) }) + + test("a memo is not returned for a client that replaced the judged engine after the final binding read", async () => { + // Turn 2 validates the memo (reads the runtime record), then reads the + // binding one last time. A replacement landing between those two reads is + // what `resolveTools` will hand the model; the runtime is asked once more, + // last. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect((await ensure("s1")).kind).toBe("reused") + let bindingReads = 0 + const prevBinding = syncInternals.resolveBinding! + syncInternals.resolveBinding = async () => { + bindingReads += 1 + // attachKey, attachKeyWorkspace, then the final attachKeyWorkspace: the + // replacement lands as the last binding read is taken. + if (bindingReads === 3) h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevBinding() + } + const second = await ensure("s1") + expect(bindingReads, "the staging assumed three binding reads on the memo path").toBeGreaterThanOrEqual(3) + expect(second.kind, "returned the memo for a client that had replaced the judged engine").not.toBe("reused") + expect(h.removes, "left the replacement registered under the cached attribution").toContain("datamate") + }) }) describe("a cached success is re-probed against the floor", () => { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 299d287f64..208b566021 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -4,7 +4,7 @@ import os, { tmpdir } from "node:os" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach, afterEach, spyOn } from "bun:test" import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" import { TestInstance } from "../fixture/fixture" @@ -1292,6 +1292,40 @@ it.instance( }), ), ) + +it.instance( + "a replacement that fails does not close a client another caller registered while it was coming up", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // Creation awaits a handshake, and nothing serializes adds to one key + // across callers: the MCP route and an IDE reload can register their + // own client under it meanwhile. A failed creation must close what IT + // was replacing, not whatever is registered by the time it fails — + // otherwise it closes the other caller's successful client and drops + // the record of what is actually running. + lastCreatedClientName = "racing" + getOrCreateClientState("racing") + yield* mcp.add("racing", { type: "local", command: ["echo", "one"] }) + + connectShouldHang = true + const slow = yield* Effect.forkChild(mcp.add("racing", { type: "local", command: ["echo", "two"], timeout: 100 })) + yield* Effect.sleep("20 millis") // let the slow add reach its (hanging) connect + connectShouldHang = false + yield* mcp.add("racing", { type: "local", command: ["echo", "three"] }) + expect(localCommand(yield* mcp.spawned("racing"))).toEqual(["echo", "three"]) + + yield* Fiber.join(slow) // times out → the failure path + const clients = yield* mcp.clients() + expect(clients["racing"], "the failed replacement closed the client another caller registered").toBeDefined() + expect(localCommand(yield* mcp.spawned("racing")), "the failed replacement dropped the record of what is running").toEqual([ + "echo", + "three", + ]) + }), + ), + { config: { mcp: {} } }, +) // altimate_change end // altimate_change start — "removed means the runtime forgets it" From 4eeea09482a7ec2bb1f23e0f82b58a5bc9ac7525 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 16:08:42 +0800 Subject: [PATCH 66/67] chore(mcp): mark the status write that moved below the bail-early check The line itself is upstream; its new position is ours, and the marker guard reads a moved line as added code in an upstream-shared file. --- packages/opencode/src/mcp/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 6161a7e2d5..b98ea2e7cb 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -957,7 +957,11 @@ export const layer = Layer.effect( // altimate_change end return result.status } + // altimate_change start — recorded after the bail-early check above, so a + // failed replacement never overwrites the status of a client another + // caller registered while it was coming up. s.status[name] = result.status + // altimate_change end // altimate_change start — remember what we actually spawned, not what the // file says. From 7a2aaa967574544786294e87bc3b4a25ad68f6e9 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 27 Aug 2026 16:23:44 +0800 Subject: [PATCH 67/67] fix(workspace): the newer add wins whichever completes first, and a global disable is honoured before the project write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more of the same shape, on the last review round. `MCP.createAndStore` guarded its failure path against a client another caller registered meanwhile, but not its success path: an older creation completing after a newer add stored its client, closing the newer one and handing the runtime back to what the older call was asked to start. The newer call now wins whichever completes first — a late result is closed, not stored, and the late call answers with what is serving. `persist` checked the node it was about to replace, which is the PROJECT file's; intent can also live in the global config the project inherits from, and a project pin written over a global disable shadows it for good, since project wins the merge. The merged view is asked once more, immediately before the write. Same window as the write's own read; named, not closed. The lifecycle mock gains a one-shot connect delay so an older add can complete after a newer one; the real-file staging that asserts the W3 residual keys to the write's own read, now the second after the guard. Reverting either fix fails a named test. --- .../src/altimate/workspace/engine-config.ts | 17 +++++++ packages/opencode/src/mcp/index.ts | 15 +++++-- .../altimate/workspace/config-on-disk.test.ts | 24 ++++++++++ .../workspace/undo-and-teardown.test.ts | 7 +-- packages/opencode/test/mcp/lifecycle.test.ts | 44 +++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts index 57d8aa424a..4319e426d7 100644 --- a/packages/opencode/src/altimate/workspace/engine-config.ts +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -38,6 +38,23 @@ export async function persist(name: string, cfg: LocalMcpConfig, configPath?: st // one write to one file is not atomic, and a disable landing between the read // and the `write` syscall is still lost. That residual is named on the PR // rather than papered over; closing it needs write-then-verify. + // The node on disk is the PROJECT file's; intent can also live in the global + // config the project inherits from. A global disable landing after the + // caller's merged read would not be on the text below — and a project pin + // written over it shadows that disable for good, since project wins the + // merge. So the merged view is asked once more, immediately before the + // write. Same window as the write's own read; named, not closed. + let merged: ExistingEntry | null + try { + merged = await existingEntry(name) + } catch (err) { + log.warn("could not confirm intent before writing the engine entry; not writing", { name, err: String(err) }) + return "disabled" + } + if (merged?.enabled === false) { + log.info("refusing to write a project entry over a disable in the merged config", { name }) + return "disabled" + } if ((await addMcpToConfig(name, cfg, configPath, { refuseIfDisabled: true })) === null) { log.info("refusing to write over an entry that is disabled on disk", { name }) return "disabled" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index b98ea2e7cb..e7a8722a60 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -957,9 +957,18 @@ export const layer = Layer.effect( // altimate_change end return result.status } - // altimate_change start — recorded after the bail-early check above, so a - // failed replacement never overwrites the status of a client another - // caller registered while it was coming up. + // altimate_change start — the newer call wins, whichever completes first. + // If another caller registered a client under this key while this one was + // coming up, this result is the OLDER intent arriving late: storing it + // would close their newer client and hand the runtime back to whatever + // this call was asked to start. Close what we made instead, leave theirs + // — client, status and launch record — and answer with what is serving. + if (s.clients[name] !== replacing) { + yield* Effect.tryPromise(() => result.mcpClient!.close()).pipe(Effect.ignore) + return s.status[name] ?? result.status + } + // Recorded after the checks above, so neither a failed nor a superseded + // replacement overwrites the status of a client another caller registered. s.status[name] = result.status // altimate_change end diff --git a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts index caeeb9c1b7..8b4b830349 100644 --- a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts +++ b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts @@ -181,6 +181,30 @@ describe("the write checks the text it is about to modify", () => { expect(after?.command).toEqual(["datamate", "start-stdio"]) expect(h.added).toHaveLength(0) }) + + test("a GLOBAL disable landing after the guard is refused before the project write", async () => { + // The project file holds an enabled node, so the write's own on-disk check + // sees nothing wrong. Intent lives in the global config the project + // inherits from, and a project pin written over a global disable shadows + // it for good (project wins the merge). So persist asks the MERGED view + // once more, immediately before writing. + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { + realPersist: true, + }) + syncInternals.projectConfigPath = async () => file + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + // reads: inspection (1), the guard (2), persist's merged re-read (3) — + // the global disable is visible from the third read on, never on disk. + return h.reads.length >= 3 && e ? { ...e, enabled: false } : e + } + const first = await ensure("s1") + expect(first.kind, "wrote a project pin over a global disable").toBe("entry-disabled") + expect((await diskEntry())?.command, "the project file was written").toEqual(["datamate", "start-stdio"]) + expect(h.added).toHaveLength(0) + }) }) describe("a disable landing before the revive is honoured", () => { diff --git a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts index c0bf18feae..2c68a7f483 100644 --- a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts +++ b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts @@ -508,8 +508,9 @@ describe("the restore refuses on the text it edits", () => { }) const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined - /** After the guard's intent read, config-file readText #1 is now addMcpToConfig's - * ONLY read (persist has no separate check read any more). */ + /** After the guard's intent read, config-file readText #1 is persist's merged + * intent re-read (the global-disable check) and #2 is addMcpToConfig's own + * read — the one the write modifies. The window under test is the write's. */ function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") { const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true }) syncInternals.projectConfigPath = async () => file @@ -532,7 +533,7 @@ describe("the restore refuses on the text it edits", () => { Filesystem.readText = async (p: string) => { if (!armed || p !== file || landed) return originalReadText(p) n += 1 - if (n !== 1) return originalReadText(p) + if (n !== 2) return originalReadText(p) landed = true if (where === "before-write-read") { writeFileSync(file, DISABLED_FILE) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 208b566021..7b27352e59 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -89,6 +89,11 @@ function getOrCreateClientState(name?: string): MockClientState { return state } +// altimate_change start — a one-shot connect delay, so a test can make an older +// add complete AFTER a newer one for the same key. +let connectDelayOnceMs = 0 +// altimate_change end + // Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang class MockStdioTransport { stderr: null = null @@ -99,6 +104,13 @@ class MockStdioTransport { async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves if (connectShouldFail) throw new Error(connectError) + // altimate_change start + if (connectDelayOnceMs) { + const delay = connectDelayOnceMs + connectDelayOnceMs = 0 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + // altimate_change end } async close() { transportCloseCount++ @@ -1326,6 +1338,38 @@ it.instance( ), { config: { mcp: {} } }, ) + +it.instance( + "an older add that completes after a newer one does not replace the newer client", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The other half of the same race: both creations SUCCEED, the older + // one last. Storing it would close the newer client and hand the runtime + // back to what the older call was asked to start. The newer call wins + // whichever completes first; the late result is closed, not stored. + lastCreatedClientName = "racing2" + getOrCreateClientState("racing2") + yield* mcp.add("racing2", { type: "local", command: ["echo", "one"] }) + + connectDelayOnceMs = 150 + const slow = yield* Effect.forkChild(mcp.add("racing2", { type: "local", command: ["echo", "two"] })) + yield* Effect.sleep("20 millis") // the slow add is inside its delayed connect + yield* mcp.add("racing2", { type: "local", command: ["echo", "three"] }) + const newer = (yield* mcp.clients())["racing2"] + + const late = yield* Fiber.join(slow) // completes late, and must not win + expect(localCommand(yield* mcp.spawned("racing2")), "an older add that completed late replaced the newer client").toEqual([ + "echo", + "three", + ]) + expect((yield* mcp.clients())["racing2"], "the newer client was closed by the late result").toBe(newer) + // The late call answers with what is serving, not with what it started. + expect(((late.status as any)["racing2"] ?? late.status).status).toBe("connected") + }), + ), + { config: { mcp: {} } }, +) // altimate_change end // altimate_change start — "removed means the runtime forgets it"