Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
dd7d1e5
feat(workspace): attach the bound workspace's integration engine
Aug 26, 2026
dfb1838
fix(workspace): reuse only an engine attributable to the bound workspace
Aug 26, 2026
ec21cc0
fix(workspace): address codex review on the engine attach
Aug 26, 2026
2480439
fix(workspace): detach rejected engines, and report the gap on reuse
Aug 26, 2026
37714b4
fix(workspace): close four attach races found by codex round 3
Aug 26, 2026
a84e7c3
fix(workspace): respect a disabled entry, and serialize attaches per …
Aug 26, 2026
a719eb5
fix(workspace): stop inferring user intent and ownership from MCP state
Aug 26, 2026
18ada3e
fix(workspace): revalidate the binding before mutating MCP state
Aug 26, 2026
1a5d85a
fix(workspace): arm the retry flag synchronously, and report unexpect…
Aug 26, 2026
9295086
fix(workspace): close the handshake window, re-probe cached attaches,…
Aug 26, 2026
791a286
feat(workspace): expose a read-only settledOutcome for other modules
Aug 26, 2026
d6f5b5b
fix(workspace): honour a live disconnect, and say when an engine is u…
Aug 26, 2026
fee2a0c
fix(workspace): announce late attachments, re-attribute cached succes…
Aug 26, 2026
4e3e28a
fix(workspace): bound the catalog lookup on both paths, and stop over…
Aug 26, 2026
d2f924a
fix(workspace): arm the abort through the body, read config before th…
Aug 26, 2026
686e3bb
fix(workspace): revalidate the binding before answering with a reused…
Aug 26, 2026
ea3566b
fix(workspace): launch the version probe through cross-spawn, and re-…
Aug 26, 2026
5a34902
test(workspace): assert the attach contract as invariants, and fix th…
Aug 26, 2026
fd5f2f8
fix(workspace): stop reporting a foreign-pinned gateway as the datama…
Aug 26, 2026
42fb816
fix(workspace): honour a config disable regardless of runtime connect…
Aug 26, 2026
3597444
fix(workspace): make "superseded" actually undo the attach
Aug 26, 2026
37dd23d
fix(workspace): revalidate after cached-success validation, and resto…
Aug 26, 2026
ce5331d
fix(workspace): tear down a disabled engine, and close the seam the r…
Aug 26, 2026
d686c3b
refactor(workspace): state what each outcome means, once, over the wh…
Aug 26, 2026
877fefd
refactor(workspace): decide what an existing entry means in one pure …
Aug 26, 2026
d76b298
refactor(workspace): one exit for every refusal, one undo for every i…
Aug 26, 2026
307bbca
refactor(workspace): split the attach module along its seams
Aug 26, 2026
4a7bd9f
test(workspace): retire the per-fix tests their invariants now subsume
Aug 26, 2026
5fe9d8a
refactor(workspace): read config and runtime as one snapshot
Aug 27, 2026
2d8bea2
refactor(workspace): make the refusal message a substitution point
Aug 27, 2026
6ea1ebb
feat(mcp): remember what was actually spawned for each server key
Aug 27, 2026
3c87de2
fix(workspace): attribution outranks connectivity, intent outranks ab…
Aug 27, 2026
1752979
fix(workspace): revive an engine with add, never with connect
Aug 27, 2026
cdd459c
fix(workspace): guard the whole world, and make every install undo it…
Aug 27, 2026
6234e92
fix(workspace): attribution asks the running engine, and the memo ask…
Aug 27, 2026
b4df0b4
fix(workspace): never write what you cannot undo, never stop waiting …
Aug 27, 2026
ecb6a38
test(workspace): assert adjacency and staleness, not routing and pres…
Aug 27, 2026
3dbb0e9
test(workspace): lift the gate's regression cases, adapted where the …
Aug 27, 2026
f5858bd
test(workspace): use spies, not a module mock, for the freshness inva…
Aug 27, 2026
16b47dd
perf(workspace): probe the PATH engine once per attach, not twice
Aug 27, 2026
e8a164f
fix(workspace): the single exit is the whole task, and it may have no…
Aug 27, 2026
726759c
fix(workspace): a failed read is never an answer, and the write check…
Aug 27, 2026
392cc5f
feat(mcp): removed means the runtime forgets it
Aug 27, 2026
557609a
fix(workspace): enforce the failed-read rule at the layer that answers
Aug 27, 2026
167966b
fix(workspace): the undo obeys the world, and the consumer asks the r…
Aug 27, 2026
5ee7309
test(workspace): give the observation tests assertions, and name what…
Aug 27, 2026
6cb70bb
test(workspace): assert the wait was honoured, not that it took measu…
Aug 27, 2026
5132fec
fix(workspace): an unbound project stays silent, whatever fails insid…
Aug 27, 2026
8c78d98
fix(workspace): say it once, guard the adjacent half, and let the und…
Aug 27, 2026
015e277
fix(workspace): bound the announcement state on the session it belong…
Aug 27, 2026
788aa14
fix(workspace): the undo's delete refuses on the same text, and pin w…
Aug 27, 2026
c5c6d14
fix(workspace): a client we started is torn down whatever is bound now
Aug 27, 2026
fcb0cb4
chore(mcp): wrap the spawn-record and same-text hunks in altimate_cha…
Aug 27, 2026
d46be88
fix(workspace): resolve the path once, key the verdict by workspace, …
Aug 27, 2026
4c7ef58
refactor(workspace): say what the code does, not what review found
Aug 27, 2026
7c48aaf
fix(workspace): ask the running engine, and undo only your own work
Aug 27, 2026
69b7824
fix(workspace): the memo asks the running engine too
Aug 27, 2026
e4c8be4
refactor(workspace): name every question, and verify identity in one …
Aug 27, 2026
3c6c3a6
fix(workspace): a stale plan is not acted on, and the probe runs wher…
Aug 27, 2026
cde3f61
feat(workspace): say once when another datamate is serving alongside us
Aug 27, 2026
f5040b3
fix(workspace): commit only what we installed, and revive the whole t…
Aug 27, 2026
37cabe5
fix(workspace): every answer that names an engine asks the same two q…
Aug 27, 2026
5de0acf
fix(workspace): an answer names an engine that is there, and is true …
Aug 27, 2026
bf30354
fix(workspace): ask about the running engine the way it runs, and kee…
Aug 27, 2026
fc941b1
fix(workspace): the memo's last question is the runtime's, and a fail…
Aug 27, 2026
4eeea09
chore(mcp): mark the status write that moved below the bail-early check
Aug 27, 2026
7a2aaa9
fix(workspace): the newer add wins whichever completes first, and a g…
Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -227,19 +232,33 @@ export namespace AltimateApi {

async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) {
const url = `${creds.altimateUrl}${endpoint}`
const res = await fetch(url, {
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}`)
// 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
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() {
Expand Down
82 changes: 74 additions & 8 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -138,22 +140,45 @@ 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) {
// 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",
metadata: { count: 0 },
output: "No integrations available.",
title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found",
metadata: { count: 0, hidden },
output: `No integrations available.${omitted}`,
}
}
const lines = ["ID | Name | Tools", "---|------|------"]
for (const i of integrations) {
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) {
Expand All @@ -167,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 <id>`, 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 {
Expand Down Expand Up @@ -228,8 +266,35 @@ 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 <id>`), 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)
// 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,
})
}

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", {
Expand Down Expand Up @@ -258,7 +323,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,
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/altimate/workspace/engine-chain.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<unknown>>()

export function projectKey(): string {
try {
return projectRoot()
} catch {
return "<no-instance>"
}
}

export function serializeAttach<T>(fn: () => Promise<T>): Promise<T> {
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
}
Loading
Loading