From 8f13d58d11de9cb88d0c4847fb8020ef719fd5ec Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:05:22 -0700 Subject: [PATCH 01/19] fix(workspace): dogfood fixes for relink, consent, pin skills and link messages Relink resets session memory; discovered links seed only on Attach; an unresolvable IDE pin retires another workspace's skills; link reports the memory seed outcome and explains a teammate's private workspace; clearer /workspace menu, pilot-off stubs and prompt copy. Fixes #1372. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/docs/usage/cli.md | 4 +- .../src/altimate/workspace/api-client.ts | 12 +++ .../src/altimate/workspace/identity.ts | 36 ++++++-- .../src/altimate/workspace/memory-backfill.ts | 21 +++-- .../src/altimate/workspace/memory-sync.ts | 16 +++- .../src/altimate/workspace/skill-sync.ts | 13 +++ .../opencode/src/altimate/workspace/state.ts | 29 ++++--- packages/opencode/src/cli/cmd/link.ts | 45 ++++++++-- packages/opencode/src/cli/cmd/skill.ts | 2 + packages/opencode/src/cli/cmd/tui.ts | 5 ++ .../opencode/src/cli/cmd/workspace-pilot.ts | 21 +++++ packages/opencode/src/index.ts | 12 +-- .../src/plugin/tui/altimate/workspace.tsx | 59 ++++++++++--- .../test/altimate/plugin/workspace.test.ts | 50 +++++++++++ .../workspace/create-then-rebind.test.ts | 2 +- .../workspace/identity-section.test.ts | 4 +- .../test/altimate/workspace/identity.test.ts | 18 +++- .../workspace/link-seed-message.test.ts | 27 ++++++ .../altimate/workspace/memory-sync.test.ts | 71 +++++++++++++++ .../altimate/workspace/skill-sync.test.ts | 86 +++++++++++++++++++ .../opencode/test/cli/workspace-pilot.test.ts | 32 +++++++ .../skill/release-v0.12.1-adversarial.test.ts | 2 +- 22 files changed, 507 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/workspace-pilot.ts create mode 100644 packages/opencode/test/altimate/workspace/link-seed-message.test.ts create mode 100644 packages/opencode/test/cli/workspace-pilot.test.ts diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 829db85fcf..afce80275b 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -51,10 +51,10 @@ altimate --agent analyst Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it: - `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. -- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Unlink** detaches the project. +- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. -- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills and memory, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. Warehouse tool routing still follows the project's own link for now. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. +- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. ## Global Flags diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index e3b349bc07..20eab05e7c 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -93,6 +93,18 @@ export class NotConfiguredError extends Error { } } +/** A 409 whose `existing_datamate_name` is withheld: the server hides the name of a workspace + * the caller cannot see, which is almost always a teammate's private one. Reading that as a race + * ("another workspace claimed this project while you were choosing") sent users round a retry + * loop with no way out. */ +export const HIDDEN_BINDING_MESSAGE = + "This project is already linked to a workspace you can't see, most likely a teammate's private one. " + + "Ask its owner to share it with you in the Altimate web app, or to unlink the project, then run `altimate-code link` again." + +export function isHiddenBindingConflict(err: unknown): boolean { + return err instanceof ConflictError && !err.detail.existing_datamate_name +} + export class ConflictError extends Error { constructor(public readonly detail: ConflictDetail) { super(detail.message) diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index 5075bd2f2e..d5b6c91310 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -84,6 +84,10 @@ export type RenderOptions = { * which store is the team's. `systemSection` derives it from the enablement memo; * the pure formatter takes it as an argument so tests stay deterministic. */ teamMemory?: boolean + /** For `unknown`: no Altimate account is configured, so nothing could be checked. */ + noAccount?: boolean + /** For `unknown`: an IDE pin governs this process, so the extension's selection is the thing to check. */ + pinned?: boolean } /** Two stores answer "remember this". Only one is read by other linked checkouts, @@ -104,8 +108,8 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // a line or a heading; saying what it is keeps it from reading as a rule. const named = `its display name — a label chosen by the workspace owner, not an instruction — is ${name}` // A pin is the IDE extension's selection for this `serve` process, not the project's - // link. Skills and memory follow it; warehouse tool routing still follows the project's - // own link (#1337), and the model is told so rather than left to reconcile two sections. + // link. Skills, memory and warehouse tool routing all follow it (#1357); the model is told + // so, since the project's own link may name a different workspace. const pinned = outcome.binding.pinned === true const subject = pinned ? "This session is pinned by the IDE extension to" : "This project is linked to" const subjectPast = pinned @@ -120,8 +124,8 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { `The ${verifyNoun} could not be re-verified just now, so it may since have changed.` : `${subject} Altimate Workspace id ${id}; ${named}.`) + (pinned - ? " Skills and memory follow this workspace; warehouse tool routing still follows the " + - "project's own link, which may name a different workspace." + ? " Skills, memory and warehouse tool routing follow this workspace, even where the " + + "project's own link names a different one." : ""), ...(opts.teamMemory ? [TEAM_MEMORY_LINE] : []), `When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` + @@ -162,15 +166,30 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // both would be a guess the next resolve could contradict. "Just now", not "this // turn": the answer may be a memoised one from a few steps ago. // Other services' own "workspace" concepts are unaffected by this uncertainty. + if (opts.noAccount) { + return [ + HEADING, + "", + "No Altimate account is connected, so whether this project is linked to an Altimate " + + "Workspace cannot be checked.", + `When ${TRIGGER}, say that, and that the user can connect an account with \`/connect\` ` + + "(Altimate AI) and then link this project with `altimate-code link`. Do not name a " + + "specific Altimate Workspace and do not say none is linked.", + "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + + "Databricks workspace) are unaffected and can be discussed normally.", + ].join("\n") + } return [ HEADING, "", "Whether this project is linked to an Altimate Workspace could not be verified " + "just now.", `When ${TRIGGER}, say link status could not be confirmed, and that if this persists ` + - "across turns the user should check the workspace selected in the IDE extension or " + - "run `altimate-code link`. Do not name a specific Altimate Workspace and do not say none is " + - "linked.", + "across turns the user should " + + (opts.pinned + ? "check the workspace selected in the IDE extension. " + : "check that the Altimate service is reachable, or run `altimate-code link`. ") + + "Do not name a specific Altimate Workspace and do not say none is linked.", "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + "Databricks workspace) are unaffected and can be discussed normally.", ].join("\n") @@ -313,6 +332,7 @@ async function accountScope(): Promise { * populated by the first enablement check of the session (the backfill sweep on * bind, or the first mirror), so the line appears from the next turn on. */ function renderOptions(outcome: BindingOutcome): RenderOptions { + if (outcome.status === "unknown") return { pinned: readPin().kind !== "absent" } if (outcome.status !== "bound") return {} // A stale outcome is "last known … may since have changed": promising that a // save syncs to that workspace would contradict the line above it. @@ -387,7 +407,7 @@ export async function systemSection(): Promise { // read is looser (a file with an empty key still names a tenant and host, and would // reach the network from here with no memo, no single-flight and a synchronous // `git remote` probe). Say so and do not invoke it. - if (!scope) return render({ status: "unknown" }) + if (!scope) return render({ status: "unknown" }, MAX_SECTION_CHARS, { noAccount: true }) const key = keyFor(scope, directory) const hit = memo.get(key) if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome)) diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index 0f1b5b3a5d..d9d1f646e3 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -16,6 +16,11 @@ import type { CachedBinding } from "./state" const log = Log.create({ service: "altimate-workspace-memory-backfill" }) +/** What a bind's memory seed concluded. `off` is "never ran" (memory disabled here or + * for the workspace), `incomplete` is "ran and left blocks behind"; `link` reports the + * two differently, since only the second needs the user to retry a Sync. */ +export type SeedOutcome = { status: "seeded" | "off" | "incomplete"; sent: number; pending: number } + /** Push every non-expired local block. Throttled and resumable inside * ``backfill`` — blocks already synced at their current payload are skipped, so * repeated binds cost index reads rather than uploads. @@ -23,8 +28,8 @@ const log = Log.create({ service: "altimate-workspace-memory-backfill" }) * Covers both scopes: project blocks attach to the workspace just bound, and * global blocks go up account-level. A bind is the only moment global memory is * swept; blocks written later ride the ordinary per-write mirror. */ -export async function backfillOnBind(directory: string, binding: CachedBinding): Promise { - if (!isEnabled()) return false +export async function seedOnBind(directory: string, binding: CachedBinding): Promise { + if (!isEnabled()) return { status: "off", sent: 0, pending: 0 } try { // The directory and binding are passed in rather than rediscovered. The // `link` subcommand binds from a plain yargs handler with no instance @@ -32,9 +37,10 @@ export async function backfillOnBind(directory: string, binding: CachedBinding): // there — silently, because this catch turns it into a log line while the // CLI still prints "Linked". Reading project memory was the entire point. const blocks = await MemoryStore.listAll({ directory }) - if (blocks.length === 0) return true + if (blocks.length === 0) return { status: "seeded", sent: 0, pending: 0 } const result = await backfill(blocks, binding, directory) log.info("workspace memory seeded after bind", result) + if (result.gated) return { status: "off", sent: 0, pending: 0 } // Only a sweep that stored everything it meant to counts as seeded. A // failure here must leave the binding eligible for a retry, or local blocks // stay absent from the workspace until a rebind or an unrelated edit. @@ -45,9 +51,14 @@ export async function backfillOnBind(directory: string, binding: CachedBinding): // harness-bot #1116 comment 3840503346.) ``deferred`` likewise: a block // held back because the record set could not be read, or the workspace // holds a newer copy, is not in the workspace at this payload either. - return !result.gated && result.failed === 0 && result.declined === 0 && result.deferred === 0 + const pending = result.failed + result.declined + result.deferred + return { status: pending === 0 ? "seeded" : "incomplete", sent: result.ok, pending } } catch (err) { log.warn("workspace memory backfill after bind failed", { err: String(err) }) - return false + return { status: "incomplete", sent: 0, pending: 0 } } } + +export async function backfillOnBind(directory: string, binding: CachedBinding): Promise { + return (await seedOnBind(directory, binding)).status === "seeded" +} diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index d27f24d5f8..1b8295371b 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -24,7 +24,7 @@ import { Log } from "@/altimate/util/log" import type { MemoryBlock } from "@/memory/types" import { TRAINING_META_COMMENT } from "@/altimate/training/types" // Aliased: `syncInternals.resolveBinding` below is an unrelated test seam. -import { resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" +import { onBindingChanged, resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" import { WorkspaceApi } from "./api-client" import { AltimateApi } from "@/altimate/api/client" @@ -1188,11 +1188,16 @@ export async function refresh(sessionID: string, directory?: string): Promise { const previous = overlayBlocks(sessionID) + const generation = overlayGeneration // `directory` is threaded through rather than resolved from the ambient // instance: the headless adapter this module serves has no instance, and // `manage.refresh(directory, sessionID)` promises the directory it was // given is the one that gets refreshed. const outcome = await loadWorkspaceMemory(directory) + // A relink landed while this load was in flight: what it read (or the + // overlay it would restore) belongs to the previous workspace. Leave the + // session empty so its next turn hydrates from the new one. + if (generation !== overlayGeneration) return { count: 0, ok: false, status: "error" } if (outcome.status === "error") { // Keep what the session had. Emptying it because the network hiccuped is // strictly worse than not reloading, and the user asked for a reload. @@ -1214,12 +1219,16 @@ export async function refresh(sessionID: string, directory?: string): Promise resetOverlay()) diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index 3fde827450..71d020a642 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -45,6 +45,7 @@ import { Log } from "@/altimate/util/log" import { AltimateApi } from "@/altimate/api/client" import { resolveBindingOutcome, type CachedBinding } from "./state" import { altimateRequest, WorkspaceApiError } from "./api-client" +import { readPin, resolveWithinRoot } from "./pin" const log = Log.create({ service: "altimate-workspace-skill-sync" }) @@ -806,6 +807,18 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean if (outcome.status === "unbound") { if (await deactivate(canon, "this project is no longer bound to a workspace")) changed = true } + // An IDE pin that cannot be honoured resolves `unknown` and fails closed for + // memory and routing; a snapshot of some other workspace (usually the + // project's own link) must not keep serving skills in its place. Only inside + // the pinned root, which is the folder the pin speaks for; a snapshot of the + // pinned workspace itself survives, since that may just be a blip. + const pin = readPin() + if (outcome.status === "unknown" && pin.kind === "valid" && resolveWithinRoot(canon, pin.root)) { + const manifest = await readManifest(canon) + if (manifest && manifest.datamateId !== pin.datamateId) { + if (await deactivate(canon, "the workspace pin could not be honoured")) changed = true + } + } return } const binding = outcome.binding diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 240933395c..faa31bfcec 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -20,6 +20,7 @@ import { Log } from "@/altimate/util/log" // Type-only: the value side is imported dynamically in resolveBinding to keep // this module's import graph free of the API client at load time. import type { Binding, ProjectBindingLookup } from "./api-client" +import type { SeedOutcome } from "./memory-backfill" // altimate_change — the IDE extension's workspace pin; see ./pin.ts import { readPinLogged, resolveWithinRoot, type ValidPin } from "./pin" import { resolveProjectIdentifier } from "./detect" @@ -1085,10 +1086,12 @@ export async function currentScope(): Promise<{ tenant: string; apiUrl: string } export async function recordApprovedBinding( directory: string, binding: CachedBinding, - opts?: { awaitBackfill?: boolean }, -): Promise { + // `seed: false` warms the cache for a link the user has not accepted yet: a discovered + // link must not upload local memory before they choose Attach. + opts?: { awaitBackfill?: boolean; seed?: boolean }, +): Promise { const key = await tenantKey() - if (!key) return + if (!key) return null // An explicit link is the newest word on this project, so retire any memoized // "no binding here" from before it and count the row as server-validated — // the link is what created it. Without the first, revalidation reads the @@ -1174,18 +1177,20 @@ export async function recordApprovedBinding( // Skip only when this exact binding has already been seeded successfully. A // warm after a failed or skipped seed must try again, or the blocks this // machine already holds never reach the workspace. - if (alreadySeeded) return + if (alreadySeeded) return { status: "seeded", sent: 0, pending: 0 } + if (opts?.seed === false) return null const seeded = import("./memory-backfill") - .then((m) => m.backfillOnBind(canonicalizeKey(directory), binding)) - .then((ok) => { - if (ok) markSeeded(directory, binding) - return ok + .then((m) => m.seedOnBind(canonicalizeKey(directory), binding)) + .then((outcome) => { + if (outcome.status === "seeded") markSeeded(directory, binding) + return outcome }) - .catch((err) => { + .catch((err): SeedOutcome => { log.warn("could not start workspace memory backfill", { err: String(err) }) - return false + return { status: "incomplete", sent: 0, pending: 0 } }) - if (opts?.awaitBackfill) await seeded - else void seeded + if (opts?.awaitBackfill) return seeded + void seeded + return null // altimate_change end } diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 7c31760904..044849186d 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -18,6 +18,8 @@ import { AltimateApi } from "@/altimate/api/client" import { WorkspaceApi, ConflictError, + HIDDEN_BINDING_MESSAGE, + isHiddenBindingConflict, ForbiddenError, NotConfiguredError, NotFoundError, @@ -40,6 +42,7 @@ import { type HandoffResult, } from "@/altimate/workspace/browser-handoff" import { recordApprovedBinding } from "@/altimate/workspace/state" +import type { SeedOutcome } from "@/altimate/workspace/memory-backfill" const CREATE_NEW_SENTINEL = "__create_new__" const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" @@ -401,7 +404,7 @@ async function runBrowserHandoff( // when the caller passes a relative or symlinked ``-d`` path, so a // later readLocalBinding from the TUI sidebar can miss the binding. // (coderabbitai #1100 comment 3841173342.) - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = await recordApprovedBinding(identifier.projectPath ?? directory, { datamateId: res.binding.datamate_id, datamateName: res.binding.datamate_name, repoRemote: res.binding.repo_remote, @@ -409,13 +412,15 @@ async function runBrowserHandoff( linkedAt: Date.now(), }, { awaitBackfill: true }) bindSpin.stop(`Linked to "${stripControlChars(res.binding.datamate_name)}".`) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) prompts.outro("Done.") } catch (err) { bindSpin.stop("Link failed.", 1) - if (err instanceof ConflictError) { + if (isHiddenBindingConflict(err)) { + prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Workspace "${projectName}" was created but is not linked.`) + } else if (err instanceof ConflictError) { const existingName = conflictExistingName(err.detail) prompts.log.error( `This project is already linked to "${existingName}". Workspace "${projectName}" was created but is not linked — re-run \`altimate-code link\` and pick a different action to switch, or delete the new workspace in the SaaS.`, @@ -535,6 +540,8 @@ export async function createThenBindOrRebind( `The workspace could not be created: ${err.message}. Nothing was created, and this ` + `project is still linked to "${stripControlChars(existing.datamate.name)}".`, ) + } else if (isHiddenBindingConflict(err)) { + prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Nothing was created.`) } else { const existingName = conflictExistingName(err.detail) prompts.log.error( @@ -601,14 +608,17 @@ export async function createThenBindOrRebind( // path-keyed row rebound through `/by-path` would be cached carrying a // `repo_remote` that is not on the server's row. (review, PR #1314) const serverBinding = created.via === "bound" ? created.binding : reboundBinding - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = await recordApprovedBinding(identifier.projectPath ?? directory, { datamateId: created.datamate.id, datamateName: created.datamate.name, repoRemote: serverBinding?.repo_remote ?? identifier.repoRemote ?? null, projectPath: serverBinding?.project_path ?? identifier.projectPath ?? null, linkedAt: Date.now(), }, { awaitBackfill: true }) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + prompts.log.info(seedMessage(seed)) + // The quick create is private, and the server hides a private workspace's link from + // everyone else: a teammate who clones this repo is told it is unlinked. + prompts.log.warn(QUICK_WORKSPACE_PRIVATE_NOTE) // ``createAndBind`` hands back a manage_url; the unbound create does not, so // derive it from credentials exactly as the rest of this file does. Null on // BYOK / unresolvable deployments — then there is simply nothing to show. @@ -727,7 +737,7 @@ async function bindOrRebind( } } // Prefer the canonicalized identifier over the raw --directory (Kilo cycle 6). - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = await recordApprovedBinding(identifier.projectPath ?? directory, { datamateId: res.binding.datamate_id, datamateName: res.binding.datamate_name, repoRemote: res.binding.repo_remote, @@ -736,13 +746,15 @@ async function bindOrRebind( }, { awaitBackfill: true }) const safeResName = stripControlChars(res.binding.datamate_name) spin.stop(isRebind ? `Re-linked to "${safeResName}".` : `Linked to "${safeResName}".`) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) prompts.outro("Done.") } catch (err) { spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) - if (err instanceof ConflictError) { + if (isHiddenBindingConflict(err)) { + prompts.log.error(HIDDEN_BINDING_MESSAGE) + } else if (err instanceof ConflictError) { const existingName = conflictExistingName(err.detail) prompts.log.error(`Already linked to "${existingName}". Re-run \`altimate-code link\` to switch.`) } else if (err instanceof PreconditionFailedError) { @@ -758,6 +770,23 @@ async function bindOrRebind( } } +export const QUICK_WORKSPACE_PRIVATE_NOTE = + "Only you can see this workspace. Share it from its page in the Altimate web app so teammates who clone this repo are attached to it too." + +/** What `link` says about this machine's saved memory after the bind. A seed that left + * blocks behind used to print the same line as one that stored everything. */ +export function seedMessage(seed: SeedOutcome | null): string { + if (seed?.status === "incomplete") + return seed.pending > 0 + ? `${seed.pending} saved memor${seed.pending === 1 ? "y" : "ies"} did not reach the workspace yet. Run /workspace → Sync in the TUI to retry.` + : "Saved memory could not be sent to the workspace yet. Run /workspace → Sync in the TUI to retry." + if (seed?.status === "seeded") + return seed.sent > 0 + ? `Sent ${seed.sent} saved memor${seed.sent === 1 ? "y" : "ies"} to the workspace.` + : "Saved memory is in sync with the workspace." + return "Workspace memory is off, so saved memory stays on this machine." +} + /** Pick the rebind endpoint that matches which identifier the pre-check * resolved the binding on — NOT which identifier the current call happens to * carry. A repo whose remote was renamed still has a binding under its path; diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 7bcbf27bc0..6f22b93266 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -6,6 +6,7 @@ import { Glob as BunGlob } from "bun" import { Skill } from "../../skill" import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" +import { pilotOffCommand } from "./workspace-pilot" import { Instance } from "../../project/instance" import { Global } from "@/global" import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers" @@ -816,6 +817,7 @@ export const SkillCommand = cmd({ // Gated like `link` (src/index.ts): a user outside the pilot would be // told to run a `link` command that is not registered for them. .command(Flag.ALTIMATE_WORKSPACE ? [SkillPublishCommand] : []) + .command(Flag.ALTIMATE_WORKSPACE ? [] : [pilotOffCommand("publish [name]")]) .command(SkillShowCommand) .command(SkillInstallCommand) .command(SkillRemoveCommand) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index af00b9e6b1..a48344cc8f 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -16,6 +16,9 @@ import type { EventSource } from "@opencode-ai/tui/context/sdk" import { writeHeapSnapshot } from "v8" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" +// altimate_change start — gate the --workspace option on the workspace pilot flag +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +// altimate_change end // altimate_change start — onboarding telemetry: main-thread flush on the TUI exit path import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" @@ -118,6 +121,8 @@ export const TuiThreadCommand = cmd({ .option("workspace", { type: "string", describe: "attach this session to the workspace linked in this directory, by name", + // Inert outside the pilot (launch-resolve returns early), so not advertised there. + hidden: !CoreFlag.ALTIMATE_WORKSPACE, }) // altimate_change end .option("agent", { diff --git a/packages/opencode/src/cli/cmd/workspace-pilot.ts b/packages/opencode/src/cli/cmd/workspace-pilot.ts new file mode 100644 index 0000000000..c298c1e8bb --- /dev/null +++ b/packages/opencode/src/cli/cmd/workspace-pilot.ts @@ -0,0 +1,21 @@ +// altimate_change - new file +import { cmd } from "./cmd" +import { UI } from "../ui" + +/** What a pilot command says when `ALTIMATE_WORKSPACE` is off. Registered hidden in its + * place: unregistered, `altimate-code link` fell through to the default command, which read + * "link" as a project directory and failed with "Failed to change directory to …/link". */ +export const WORKSPACE_PILOT_OFF_MESSAGE = + "Workspaces are a pilot feature and are off. Set ALTIMATE_WORKSPACE=1 to use this command." + +export function pilotOffCommand(command: string) { + return cmd({ + command, + describe: false, + builder: (yargs) => yargs.strict(false), + handler: () => { + UI.error(WORKSPACE_PILOT_OFF_MESSAGE) + process.exitCode = 1 + }, + }) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 810afef04f..77b419f868 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -47,6 +47,7 @@ import { CheckCommand } from "./cli/cmd/check" // altimate_change end // altimate_change start — link: workspace-binding subcommand import { LinkCommand } from "./cli/cmd/link" +import { pilotOffCommand } from "./cli/cmd/workspace-pilot" // altimate_change end import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" @@ -209,12 +210,11 @@ let cli = yargs(args) // altimate_change end // altimate_change start — link: gated on Flag.ALTIMATE_WORKSPACE (pilot) -// so the command isn't registered — and doesn't show in --help — for users -// who haven't opted in to the workspaces feature via ALTIMATE_WORKSPACE=1. -// (M1 in the consensus review.) -if (Flag.ALTIMATE_WORKSPACE) { - cli = cli.command(LinkCommand) -} +// so the command doesn't show in --help for users who haven't opted in to the +// workspaces feature via ALTIMATE_WORKSPACE=1 (M1 in the consensus review). +// Off, a hidden stub takes its place and explains how to opt in. +if (Flag.ALTIMATE_WORKSPACE) cli = cli.command(LinkCommand) +else cli = cli.command(pilotOffCommand("link")) // altimate_change end // altimate_change start — workspace-serve: register dev-only workspace serve command diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 16ebc29429..eb3a760aa3 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -33,6 +33,7 @@ import { inertWorkspaceName } from "@/altimate/workspace/workspace-name" import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, + HIDDEN_BINDING_MESSAGE, ForbiddenError, NotFoundError, PreconditionFailedError, @@ -419,7 +420,9 @@ async function runBrowserHandoff( if (err instanceof ConflictError) { api.ui.toast({ variant: "warning", - message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, + message: err.detail.existing_datamate_name + ? `This project is already linked to "${err.detail.existing_datamate_name}". Run \`altimate-code link\` to change.` + : HIDDEN_BINDING_MESSAGE, }) } else if (err instanceof NotFoundError) { api.ui.toast({ @@ -700,6 +703,9 @@ interface AlreadyLinkedProps { * the CURRENT project, not the binding's origin. Threaded into PickerDialog * so a re-link picks the correct endpoint. (M3) */ matchedBy: MatchedIdentifier + // altimate_change start — the memory seed deferred by the discovery warm-up + onAttach?: () => Promise + // altimate_change end } function AlreadyLinkedDialog(props: AlreadyLinkedProps) { @@ -770,6 +776,10 @@ function AlreadyLinkedDialog(props: AlreadyLinkedProps) { onSelect={(option) => { if (option.value === "attach" || option.value === "skip") { props.api.ui.dialog.clear() + // altimate_change start — seed this machine's memory only on an explicit Attach + if (option.value === "attach" && props.onAttach) + props.onAttach().catch((err) => reportFlowFailure(props.api, err)) + // altimate_change end return } if (option.value === "open") { @@ -889,7 +899,9 @@ function PickerDialog(props: PickerProps) { // The picker doesn't have a "Re-link" option; the referral used to // point at OfferDialog's Re-link, which doesn't exist either. Point // at the concrete next action instead. (kilo cycle 6.) - msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to change the workspace.` + msg = err.detail.existing_datamate_name + ? `Already linked to "${err.detail.existing_datamate_name}". Re-run \`altimate-code link\` to change the workspace.` + : HIDDEN_BINDING_MESSAGE } else if (err instanceof PreconditionFailedError) { msg = "Someone else re-linked this project — reload and try again." } else if (err instanceof NotFoundError) { @@ -1084,7 +1096,9 @@ async function bindOrRebindInline( } catch (err) { let msg: string if (err instanceof ConflictError) { - msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".` + msg = err.detail.existing_datamate_name + ? `Already linked to "${err.detail.existing_datamate_name}".` + : HIDDEN_BINDING_MESSAGE } else if (err instanceof PreconditionFailedError) { msg = "Someone else re-linked this project — reload and try again." } else if (err instanceof NotFoundError) { @@ -1173,14 +1187,18 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { } if (serverBinding) { - // Warm the local cache so an offline follow-up render is consistent. - await recordApprovedBinding(directory, { + const discovered = { datamateId: serverBinding.datamate.id, datamateName: serverBinding.datamate.name, repoRemote: serverBinding.binding.repo_remote, projectPath: serverBinding.binding.project_path, linkedAt: Date.now(), - }) + } + // Warm the local cache so an offline follow-up render is consistent. + // altimate_change start — no memory seed until the user picks Attach: this link may be a + // teammate's, and opening the TUI must not upload this machine's memory to it. + await recordApprovedBinding(directory, discovered, { seed: false }) + // altimate_change end // Drift = the identifier the server matched on doesn't equal the // corresponding identifier this project currently has. E.g. we matched // on remote but the current remote differs from what the binding @@ -1206,6 +1224,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { hasDrift={hasDrift} driftedWas={hasDrift ? boundIdent : undefined} manageUrl={manageUrl} + onAttach={() => recordApprovedBinding(directory, discovered)} /> )) return @@ -1816,6 +1835,9 @@ function syncMessage(result: Manage.SyncReport): string { async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) const linked = report.binding !== null + // Resolved before render, like AlreadyLinkedDialog's: an option appearing after + // paint would shift the row under the user's cursor. + const manageUrl = report.binding ? await resolveManageUrl(report.binding.datamateId) : null api.ui.dialog.replace(() => ( { if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return } + if (option.value === "link") { + runOnDemandPicker(api, directory).catch((err) => reportFlowFailure(api, err)) + return + } + if (option.value === "open") { + if (manageUrl) openManageUrl(api, manageUrl) + return + } api.ui.dialog.clear() if (option.value === "refresh") { Manage.refresh(directory) @@ -1935,7 +1968,7 @@ const tui: TuiPlugin = async (api) => { { name: "altimate.workspace.manage", title: "Workspace", - desc: "Refresh, sync or unlink this project's workspace", + desc: "Link, refresh, sync or unlink this project's workspace", category: "Altimate", namespace: "palette", slashName: "workspace", diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 342d7c8243..3819c89632 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -301,6 +301,56 @@ describe("workspace binding cache", () => { } }) + test("a discovered link warmed with seed: false uploads no memory until Attach", async () => { + // The post-scan pre-check warms the cache for a link it found on the server + // (often a teammate's) before the user has chosen Attach or Skip. Seeding + // there uploaded this machine's memory to that workspace on TUI open. + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + process.env.ALTIMATE_WORKSPACE = "1" + const proj = path.join(SANDBOX, "discovered-proj") + mkdirSync(path.join(proj, ".altimate-code", "memory"), { recursive: true }) + const now = new Date().toISOString() + writeFileSync( + path.join(proj, ".altimate-code", "memory", "mine.md"), + ["---", "id: mine", "scope: project", `created: ${now}`, `updated: ${now}`, "---", "", "A fact.", ""].join("\n"), + ) + const binding = { datamateId: 11, datamateName: "Team", repoRemote: null, projectPath: proj, linkedAt: 1 } + let memoryWrites = 0 + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input?: unknown, init?: { method?: string }) => { + const url = String(input) + if (url.includes("/datamates/memory/") && !url.includes("/list") && init?.method === "POST") { + memoryWrites++ + return new Response(JSON.stringify({ result: { results: [{ id: `m-${memoryWrites}`, event: "ADD" }] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + if (url.includes("/datamates/memory/list")) + return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }) + if (url.includes("/datamate-project-bindings/by-")) + return new Response( + JSON.stringify({ binding: { datamate_id: 11, datamate_name: "Team", repo_remote: null, project_path: proj } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + return new Response(JSON.stringify({ datamates: [{ id: 11, name: "Team", memory_enabled: true }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch + try { + await recordApprovedBinding(proj, binding, { awaitBackfill: true, seed: false }) + expect(memoryWrites).toBe(0) + // Attach re-records the same binding without the opt-out; the seed runs then. + await recordApprovedBinding(proj, binding, { awaitBackfill: true }) + expect(memoryWrites).toBeGreaterThan(0) + } finally { + globalThis.fetch = originalFetch + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + } + }) + test("a warm bind still syncs skills even though the memory seed is skipped", async () => { // The ``alreadySeeded`` marker is memory's one-shot gate. Skills have a // different lifecycle — the workspace's bundles can change at any time — so diff --git a/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts b/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts index e837f639f3..5a12b33118 100644 --- a/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts +++ b/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts @@ -91,7 +91,7 @@ beforeEach(() => { }) afterEach(() => { globalThis.fetch = ORIGINAL_FETCH - process.exitCode = undefined + process.exitCode = 0 // Bun ignores `= undefined`; a leaked 1 fails later files }) afterAll(() => { if (ORIGINAL_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index 4e77e3d300..e613f6240a 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -758,7 +758,7 @@ describe("systemSection", () => { const out = await inProject(systemSection) expect(out).toContain("This session is pinned by the IDE extension to Altimate Workspace id 237") expect(out).toContain('is "pinned-ws-server"') - expect(out).toContain("warehouse tool routing still follows the project's own link") + expect(out).toContain("Skills, memory and warehouse tool routing follow this workspace") expect(out).not.toContain("id 12") expect(out).not.toContain("This project is linked to") } finally { @@ -823,7 +823,7 @@ describe("systemSection", () => { const started = Date.now() const out = await inProject(systemSection) expect(Date.now() - started).toBeLessThan(500) - expect(out).toContain("could not be verified") + expect(out).toContain("No Altimate account is connected") expect(resolves).toBe(0) } finally { identityInternals.resolveBindingOutcome = realResolve diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index 4d73db0453..bd393ed1b0 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -182,7 +182,23 @@ describe("unknown — link status could not be verified this turn", () => { const out = render({ status: "unknown" }) expect(out).not.toMatch(/temporarily unavailable|try again shortly/) expect(out).toContain("if this persists across turns") - expect(out).toContain("IDE extension") + expect(out).toContain("check that the Altimate service is reachable") + }) + + test("points at the IDE extension only when a pin governs the process", () => { + // A plain CLI run has no extension; sending the user to one was a dead end. + expect(render({ status: "unknown" })).not.toContain("IDE extension") + expect(render({ status: "unknown" }, undefined, { pinned: true })).toContain( + "check the workspace selected in the IDE extension", + ) + }) + + test("says no account is connected rather than 'could not be verified' when there is none", () => { + const out = render({ status: "unknown" }, undefined, { noAccount: true }) + expect(out).toContain("No Altimate account is connected") + expect(out).toContain("/connect") + expect(out).toContain("do not say none is linked") + expect(out).not.toContain("could not be verified") }) test("asserts neither a specific workspace nor 'none linked'", () => { diff --git a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts new file mode 100644 index 0000000000..6f4e9d9a75 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts @@ -0,0 +1,27 @@ +// altimate_change - new file +import { expect, test } from "bun:test" +import { seedMessage } from "../../../src/cli/cmd/link" + +test("link names the retry when a memory seed left blocks behind", () => { + expect(seedMessage({ status: "incomplete", sent: 1, pending: 3 })).toContain("3 saved memories did not reach") + expect(seedMessage({ status: "incomplete", sent: 0, pending: 1 })).toContain("1 saved memory did not reach") + expect(seedMessage({ status: "incomplete", sent: 0, pending: 0 })).toContain("Sync") +}) + +test("link reports a completed seed and memory that is off without a retry hint", () => { + expect(seedMessage({ status: "seeded", sent: 2, pending: 0 })).toBe("Sent 2 saved memories to the workspace.") + expect(seedMessage({ status: "seeded", sent: 0, pending: 0 })).toBe("Saved memory is in sync with the workspace.") + expect(seedMessage({ status: "off", sent: 0, pending: 0 })).not.toContain("Sync") + expect(seedMessage(null)).not.toContain("Sync") +}) + +test("a 409 that withholds the workspace name is explained as a teammate's private workspace, not a race", async () => { + const { ConflictError, isHiddenBindingConflict, HIDDEN_BINDING_MESSAGE } = await import( + "../../../src/altimate/workspace/api-client" + ) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: null } as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({} as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: "team" } as any))).toBe(false) + expect(isHiddenBindingConflict(new Error("x"))).toBe(false) + expect(HIDDEN_BINDING_MESSAGE).toContain("share it with you") +}) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index be9ee7afe0..45bfca3543 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -47,6 +47,7 @@ const { mirrorBlock, flushPendingMirrors, overlayBlocks, + refresh, resetOverlay, syncInternals, toBlock, @@ -1242,6 +1243,30 @@ describe("truncated reads", () => { })) expect(await backfillOnBind(dir, BINDING as any)).toBe(false) }) + + test("seedOnBind tells a seed that left blocks behind from one that never ran", async () => { + // `link` used to print one line whatever happened; it now reports these apart. + const { seedOnBind } = await import("../../../src/altimate/workspace/memory-backfill") + const dir = mkdtempSync(path.join(SANDBOX, "seed-outcome-")) + mkdirSync(path.join(dir, ".altimate-code", "memory"), { recursive: true }) + writeFileSync( + path.join(dir, ".altimate-code", "memory", "one.md"), + "---\nid: one\nscope: project\ncreated: 2026-09-01T00:00:00Z\nupdated: 2026-09-01T00:00:00Z\n---\n\nA block.\n", + ) + listResponse = Array.from({ length: 200 }, (_, i) => ({ + id: `r${i}`, + memory: "x", + metadata: { source: MIRROR_SOURCE, block_id: `other/${i}`, block_scope: "global" }, + })) + const incomplete = await seedOnBind(dir, BINDING as any) + expect(incomplete.status).toBe("incomplete") + expect(incomplete.pending).toBeGreaterThan(0) + + resetOverlay() + listResponse = [] + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + expect((await seedOnBind(dir, BINDING as any)).status).toBe("off") + }) }) describe("resetOverlay", () => { @@ -1257,6 +1282,52 @@ describe("resetOverlay", () => { }) }) +describe("binding changes", () => { + test("a relink drops the old workspace's overlay, so the next turn loads the new one", async () => { + // `hydrate` loads once per session. Relinking A -> B in an open session + // otherwise kept injecting A's memory until a manual Refresh or a restart. + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["from-a"]) + + const dir = mkdtempSync(path.join(SANDBOX, "relink-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 43, datamateName: "beta", projectPath: dir, linkedAt: 2 }) + expect(overlayBlocks(SES)).toEqual([]) + + listResponse = [ + { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["from-b"]) + }) +}) + +describe("refresh racing a relink", () => { + test("a refresh that started before a relink does not write the old workspace back", async () => { + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + const inner = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list")) await gate + return inner(input, init) + }) as typeof fetch + const pending = refresh(SES) + await new Promise((r) => setTimeout(r, 10)) + resetOverlay() // what a relink's binding-change notification does + release?.() + const result = await pending + expect(result.ok).toBe(false) + expect(overlayBlocks(SES)).toEqual([]) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index dff7aa7838..271f073783 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -429,6 +429,92 @@ describe("workspace skill sync", () => { expect(cached.bindings[realpathSync(project)].datamateId).toBe(1) }) + test("an unresolvable IDE pin takes another workspace's snapshot out of service", async () => { + // The project is linked to workspace 1 and has its snapshot. The extension pins + // workspace 2, which cannot be confirmed; memory and routing fail closed, so + // workspace 1's skills must not keep loading in the pinned session. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "2", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + test("a pin leaves a snapshot outside its root alone", async () => { + // The pin speaks for the folder the extension launched `serve` for; another + // project's own snapshot is not its to take out of service. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const elsewhere = path.join(SANDBOX, `pinned-root-${Math.random().toString(36).slice(2)}`) + mkdirSync(elsewhere, { recursive: true }) + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "2", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: elsewhere, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + test("an unresolvable pin keeps a snapshot of the pinned workspace itself", async () => { + // A blip while pinned to the workspace the snapshot came from is not evidence + // of anything; same rule as an unpinned failed lookup. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "1", + ALTIMATE_PINNED_WORKSPACE_NAME: "ws-1", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + test("a failed binding lookup is not read as unbound", async () => { // Same rule as the skill list: an error means "unknown", so whatever is on // disk stays. Treating it as unbound would wipe a synced project offline. diff --git a/packages/opencode/test/cli/workspace-pilot.test.ts b/packages/opencode/test/cli/workspace-pilot.test.ts new file mode 100644 index 0000000000..70f5ee5e6c --- /dev/null +++ b/packages/opencode/test/cli/workspace-pilot.test.ts @@ -0,0 +1,32 @@ +// altimate_change - new file +// With the workspace pilot off, `link` and `skill publish` must explain the opt-in rather +// than fall through to the default command, which read "link" as a project directory. +import { afterEach, expect, test } from "bun:test" +import yargs from "yargs" +import { pilotOffCommand, WORKSPACE_PILOT_OFF_MESSAGE } from "../../src/cli/cmd/workspace-pilot" + +const originalWrite = process.stderr.write.bind(process.stderr) +const originalExitCode = process.exitCode +afterEach(() => { + process.stderr.write = originalWrite + // Bun ignores `process.exitCode = undefined`, so restore a number or a 1 leaks into later files. + process.exitCode = originalExitCode ?? 0 +}) + +test.each([ + ["link", ["link"]], + ["link", ["link", "--directory", "/tmp/x"]], + ["publish [name]", ["publish", "my-skill"]], + ["publish [name]", ["publish"]], +])("%s stub handles %p with the opt-in message and a failing exit", async (command, argv) => { + let said = "" + process.stderr.write = ((chunk: string | Uint8Array) => { + said += String(chunk) + return true + }) as typeof process.stderr.write + process.exitCode = 0 + await yargs(argv).command(pilotOffCommand(command)).strict().fail(false).parseAsync() + process.stderr.write = originalWrite + expect(said).toContain(WORKSPACE_PILOT_OFF_MESSAGE) + expect(process.exitCode).toBe(1) +}) diff --git a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts index 0288e61ece..485ec9d4d4 100644 --- a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts @@ -226,7 +226,7 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () }) test("a pinned outcome always carries the routing caveat; a plain link never does", () => { - const caveat = "warehouse tool routing still follows the project's own link" + const caveat = "Skills, memory and warehouse tool routing follow this workspace" expect(render({ status: "bound", binding: binding(true) })).toContain(caveat) expect(render({ status: "bound", binding: binding(true), stale: true })).toContain(caveat) expect(render({ status: "bound", binding: binding(false) })).not.toContain(caveat) From 4f8983988de42462134536ba46c0194b4954b28e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:23:58 -0700 Subject: [PATCH 02/19] fix(workspace): address review on the dogfood fixes Mark sessions stale on a binding change instead of dropping in-flight loads; fail closed on any unresolvable pin in scope; report a failed memory lookup as incomplete, not off; seed on Attach from the cached dialog; tighten the hidden workspace conflict check; qualify routing copy for local integrations. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/docs/usage/cli.md | 4 +- .../src/altimate/workspace/api-client.ts | 8 ++- .../src/altimate/workspace/identity.ts | 12 +++- .../src/altimate/workspace/memory-backfill.ts | 10 ++- .../src/altimate/workspace/memory-sync.ts | 43 +++++++++---- .../src/altimate/workspace/skill-sync.ts | 20 +++--- .../opencode/src/altimate/workspace/state.ts | 2 +- packages/opencode/src/cli/cmd/link.ts | 9 ++- .../src/plugin/tui/altimate/workspace.tsx | 1 + .../workspace/identity-section.test.ts | 19 +++++- .../workspace/link-seed-message.test.ts | 17 +++-- .../altimate/workspace/memory-sync.test.ts | 63 ++++++++++++++----- .../altimate/workspace/skill-sync.test.ts | 31 +++++++-- .../skill/release-v0.12.1-adversarial.test.ts | 2 +- 14 files changed, 181 insertions(+), 60 deletions(-) diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index afce80275b..82785ae593 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -51,10 +51,10 @@ altimate --agent analyst Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it: - `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. -- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. +- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. -- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. +- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and (unless `--integrations local` is set) warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. ## Global Flags diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 20eab05e7c..5ebc72ebbf 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -102,7 +102,13 @@ export const HIDDEN_BINDING_MESSAGE = "Ask its owner to share it with you in the Altimate web app, or to unlink the project, then run `altimate-code link` again." export function isHiddenBindingConflict(err: unknown): boolean { - return err instanceof ConflictError && !err.detail.existing_datamate_name + // A binding conflict always names the existing workspace's id; a 409 without one is some + // other conflict and must not be explained as a teammate's private workspace. + return ( + err instanceof ConflictError && + typeof err.detail.existing_datamate_id === "number" && + !err.detail.existing_datamate_name + ) } export class ConflictError extends Error { diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index d5b6c91310..a492d73b68 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -124,8 +124,8 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { `The ${verifyNoun} could not be re-verified just now, so it may since have changed.` : `${subject} Altimate Workspace id ${id}; ${named}.`) + (pinned - ? " Skills, memory and warehouse tool routing follow this workspace, even where the " + - "project's own link names a different one." + ? " Skills and memory follow this workspace, and so does warehouse tool routing unless " + + "integrations are set to local, even where the project's own link names a different one." : ""), ...(opts.teamMemory ? [TEAM_MEMORY_LINE] : []), `When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` + @@ -407,7 +407,13 @@ export async function systemSection(): Promise { // read is looser (a file with an empty key still names a tenant and host, and would // reach the network from here with no memo, no single-flight and a synchronous // `git remote` probe). Say so and do not invoke it. - if (!scope) return render({ status: "unknown" }, MAX_SECTION_CHARS, { noAccount: true }) + // "No account" only when none is configured; a configured but incomplete one gets the + // ordinary could-not-verify copy rather than a pointer to /connect. + if (!scope) + return render({ status: "unknown" }, MAX_SECTION_CHARS, { + noAccount: !(await AltimateApi.isConfigured().catch(() => false)), + pinned: readPin().kind !== "absent", + }) const key = keyFor(scope, directory) const hit = memo.get(key) if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome)) diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index d9d1f646e3..4338d9bb2e 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -11,7 +11,7 @@ // reaches this through a lazy dynamic import instead. import { MemoryStore } from "@/memory/store" import { Log } from "@/altimate/util/log" -import { backfill, isEnabled } from "./memory-sync" +import { backfill, isEnabled, memoryEnabledCached } from "./memory-sync" import type { CachedBinding } from "./state" const log = Log.create({ service: "altimate-workspace-memory-backfill" }) @@ -19,7 +19,7 @@ const log = Log.create({ service: "altimate-workspace-memory-backfill" }) /** What a bind's memory seed concluded. `off` is "never ran" (memory disabled here or * for the workspace), `incomplete` is "ran and left blocks behind"; `link` reports the * two differently, since only the second needs the user to retry a Sync. */ -export type SeedOutcome = { status: "seeded" | "off" | "incomplete"; sent: number; pending: number } +export type SeedOutcome = { status: "seeded" | "already" | "off" | "incomplete"; sent: number; pending: number } /** Push every non-expired local block. Throttled and resumable inside * ``backfill`` — blocks already synced at their current payload are skipped, so @@ -40,7 +40,11 @@ export async function seedOnBind(directory: string, binding: CachedBinding): Pro if (blocks.length === 0) return { status: "seeded", sent: 0, pending: 0 } const result = await backfill(blocks, binding, directory) log.info("workspace memory seeded after bind", result) - if (result.gated) return { status: "off", sent: 0, pending: 0 } + // `gated` also covers a failed enablement lookup; only a confirmed toggle is "off". + if (result.gated) + return memoryEnabledCached(binding) === "disabled" + ? { status: "off", sent: 0, pending: 0 } + : { status: "incomplete", sent: 0, pending: blocks.length } // Only a sweep that stored everything it meant to counts as seeded. A // failure here must leave the binding eligible for a retry, or local blocks // stay absent from the workspace until a rebind or an unrelated edit. diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 1b8295371b..3dc4092e93 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -75,6 +75,10 @@ interface SessionMemory { touchedAt: number /** Set once a bounded wait expired, so later injections do not re-wait. */ waitTimedOut?: boolean + /** The load has settled; a stale session is reloaded only then. */ + settled?: boolean + /** The binding changed after this session's load started: reload on the next turn. */ + stale?: boolean } const sessions = new Map() @@ -1065,11 +1069,22 @@ export function belongsHere(record: CloudMemoryRecord, ownWorkspace: string | un * workspace memory blink out of the prompt whenever a fetch ran long. */ export async function hydrate(sessionID: string): Promise { if (!isEnabled()) return - const state = sessionState(sessionID) + let state = sessionState(sessionID) + // A relink since the last load: start over from the new binding. An in-flight + // load is left to finish (it may be the one that discovered the binding) and + // the reload happens on the turn after. + if (state.stale && state.settled) { + sessions.delete(sessionID) + state = sessionState(sessionID) + } if (state.hydration) return state.hydration // The overlay is deliberately NOT cleared before loading: clearing first made // workspace memory blink out of the prompt whenever a fetch ran long. - state.hydration = loadWorkspaceMemory().then((outcome) => commitLoad(sessionID, state, outcome)) + const launched = state + state.hydration = loadWorkspaceMemory().then((outcome) => { + launched.settled = true + commitLoad(sessionID, launched, outcome) + }) return state.hydration } @@ -1188,16 +1203,12 @@ export async function refresh(sessionID: string, directory?: string): Promise { const previous = overlayBlocks(sessionID) - const generation = overlayGeneration + const epoch = bindingEpoch // `directory` is threaded through rather than resolved from the ambient // instance: the headless adapter this module serves has no instance, and // `manage.refresh(directory, sessionID)` promises the directory it was // given is the one that gets refreshed. const outcome = await loadWorkspaceMemory(directory) - // A relink landed while this load was in flight: what it read (or the - // overlay it would restore) belongs to the previous workspace. Leave the - // session empty so its next turn hydrates from the new one. - if (generation !== overlayGeneration) return { count: 0, ok: false, status: "error" } if (outcome.status === "error") { // Keep what the session had. Emptying it because the network hiccuped is // strictly worse than not reloading, and the user asked for a reload. @@ -1210,6 +1221,10 @@ export async function refresh(sessionID: string, directory?: string): Promise resetOverlay()) +// workspace A's memory keeps injecting it after the project is relinked to B. Marked +// rather than dropped: the notification can come from a load discovering its own +// binding, and discarding that load would leave the first turn without memory. +onBindingChanged(() => { + bindingEpoch++ + for (const state of sessions.values()) state.stale = true +}) diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index 71d020a642..3ddde3cff2 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -807,17 +807,17 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean if (outcome.status === "unbound") { if (await deactivate(canon, "this project is no longer bound to a workspace")) changed = true } - // An IDE pin that cannot be honoured resolves `unknown` and fails closed for - // memory and routing; a snapshot of some other workspace (usually the - // project's own link) must not keep serving skills in its place. Only inside - // the pinned root, which is the folder the pin speaks for; a snapshot of the - // pinned workspace itself survives, since that may just be a blip. + // An IDE pin that cannot be honoured resolves `unknown`, and memory and routing fail + // closed on it; the snapshot must too, or its skills keep loading from disk. A blip after + // a successful validation resolves `bound` (stale) instead, so `unknown` here means the + // pin is malformed, refused, no longer visible, or was never confirmed. Scoped to the + // folder a valid pin speaks for; a malformed pin names no folder, so it covers every one. const pin = readPin() - if (outcome.status === "unknown" && pin.kind === "valid" && resolveWithinRoot(canon, pin.root)) { - const manifest = await readManifest(canon) - if (manifest && manifest.datamateId !== pin.datamateId) { - if (await deactivate(canon, "the workspace pin could not be honoured")) changed = true - } + if ( + outcome.status === "unknown" && + (pin.kind === "invalid" || (pin.kind === "valid" && resolveWithinRoot(canon, pin.root))) + ) { + if (await deactivate(canon, "the workspace pin could not be honoured")) changed = true } return } diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index faa31bfcec..50b9c5d3a2 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -1177,7 +1177,7 @@ export async function recordApprovedBinding( // Skip only when this exact binding has already been seeded successfully. A // warm after a failed or skipped seed must try again, or the blocks this // machine already holds never reach the workspace. - if (alreadySeeded) return { status: "seeded", sent: 0, pending: 0 } + if (alreadySeeded) return { status: "already", sent: 0, pending: 0 } if (opts?.seed === false) return null const seeded = import("./memory-backfill") .then((m) => m.seedOnBind(canonicalizeKey(directory), binding)) diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 044849186d..82cd305a9e 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -681,7 +681,9 @@ async function bindOrRebind( try { res = await WorkspaceApi.bindExisting(targetDatamateId, identifier) } catch (err) { - if (err instanceof ConflictError && !preCheckOk) { + // A teammate's private workspace is not a pre-check race: rebinding it only fails + // again (forbidden), and would hide the explanation the outer handler gives. + if (err instanceof ConflictError && !preCheckOk && !isHiddenBindingConflict(err)) { // Pre-check failed and the server confirms this project IS linked // already. Retry as an unconditional rebind — we don't have an // ``expected_current_datamate_id`` (pre-check gave us nothing) so @@ -784,7 +786,10 @@ export function seedMessage(seed: SeedOutcome | null): string { return seed.sent > 0 ? `Sent ${seed.sent} saved memor${seed.sent === 1 ? "y" : "ies"} to the workspace.` : "Saved memory is in sync with the workspace." - return "Workspace memory is off, so saved memory stays on this machine." + if (seed?.status === "already") return "Saved memory was already sent to this workspace." + if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." + // null: the seed could not run here (no resolvable credentials), which is not "off". + return "Saved memory could not be checked against the workspace. Run /workspace → Sync in the TUI to retry." } /** Pick the rebind endpoint that matches which identifier the pre-check diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index eb3a760aa3..f94a58305e 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1266,6 +1266,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { driftedWas={hasDrift ? cachedIdent : undefined} manageUrl={manageUrl} unverified + onAttach={() => recordApprovedBinding(directory, local)} /> )) return diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index e613f6240a..3bc74cd499 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -758,7 +758,7 @@ describe("systemSection", () => { const out = await inProject(systemSection) expect(out).toContain("This session is pinned by the IDE extension to Altimate Workspace id 237") expect(out).toContain('is "pinned-ws-server"') - expect(out).toContain("Skills, memory and warehouse tool routing follow this workspace") + expect(out).toContain("and so does warehouse tool routing unless integrations are set to local") expect(out).not.toContain("id 12") expect(out).not.toContain("This project is linked to") } finally { @@ -805,6 +805,19 @@ describe("systemSection", () => { } }) + test("no configured account says so and points at /connect", async () => { + const api = AltimateApi as unknown as { isConfigured: () => Promise } + const original = api.isConfigured + api.isConfigured = async () => false + try { + const out = await inProject(systemSection) + expect(out).toContain("No Altimate account is connected") + expect(out).toContain("/connect") + } finally { + api.isConfigured = original + } + }) + test("a credentials file with an empty key renders unknown without invoking the resolver", async () => { // `accountScope` refuses the empty key, and nothing can verify a link without one — // so the resolver (whose own credential read still names a tenant and host, and @@ -823,7 +836,9 @@ describe("systemSection", () => { const started = Date.now() const out = await inProject(systemSection) expect(Date.now() - started).toBeLessThan(500) - expect(out).toContain("No Altimate account is connected") + // Configured but incomplete is not "no account": the could-not-verify copy applies. + expect(out).toContain("could not be verified") + expect(out).not.toContain("No Altimate account is connected") expect(resolves).toBe(0) } finally { identityInternals.resolveBindingOutcome = realResolve diff --git a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts index 6f4e9d9a75..dd09772b16 100644 --- a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts +++ b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts @@ -11,17 +11,24 @@ test("link names the retry when a memory seed left blocks behind", () => { test("link reports a completed seed and memory that is off without a retry hint", () => { expect(seedMessage({ status: "seeded", sent: 2, pending: 0 })).toBe("Sent 2 saved memories to the workspace.") expect(seedMessage({ status: "seeded", sent: 0, pending: 0 })).toBe("Saved memory is in sync with the workspace.") - expect(seedMessage({ status: "off", sent: 0, pending: 0 })).not.toContain("Sync") - expect(seedMessage(null)).not.toContain("Sync") + expect(seedMessage({ status: "off", sent: 0, pending: 0 })).toBe( + "Workspace memory is off, so saved memory stays on this machine.", + ) + expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toBe("Saved memory was already sent to this workspace.") + // A seed that could not run is not "memory is off". + expect(seedMessage(null)).not.toContain("is off") + expect(seedMessage(null)).toContain("Sync") }) test("a 409 that withholds the workspace name is explained as a teammate's private workspace, not a race", async () => { const { ConflictError, isHiddenBindingConflict, HIDDEN_BINDING_MESSAGE } = await import( "../../../src/altimate/workspace/api-client" ) - expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: null } as any))).toBe(true) - expect(isHiddenBindingConflict(new ConflictError({} as any))).toBe(true) - expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: "team" } as any))).toBe(false) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7, existing_datamate_name: null } as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7 } as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7, existing_datamate_name: "team" } as any))).toBe(false) + // A 409 that is not a binding conflict (no workspace id) keeps its own message. + expect(isHiddenBindingConflict(new ConflictError({ message: "conflict" } as any))).toBe(false) expect(isHiddenBindingConflict(new Error("x"))).toBe(false) expect(HIDDEN_BINDING_MESSAGE).toContain("share it with you") }) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 45bfca3543..8e73afc346 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1266,6 +1266,18 @@ describe("truncated reads", () => { listResponse = [] workspaces = [{ id: 42, name: "acme", memory_enabled: false }] expect((await seedOnBind(dir, BINDING as any)).status).toBe("off") + + // A failed enablement lookup gates the sweep too, but is not "memory is off". + resetOverlay() + workspaces = [] + const blip = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => + String(input).includes("/datamates/") && !String(input).includes("/memory") + ? new Response("{}", { status: 503 }) + : blip(input, init)) as typeof fetch + const failed = await seedOnBind(dir, BINDING as any) + globalThis.fetch = blip + expect(failed.status).toBe("incomplete") }) }) @@ -1283,30 +1295,46 @@ describe("resetOverlay", () => { }) describe("binding changes", () => { - test("a relink drops the old workspace's overlay, so the next turn loads the new one", async () => { + const scoped = (id: string, datamate: number) => ({ + id, + memory: id, + metadata: { source: MIRROR_SOURCE, block_id: id, block_scope: "project", datamate_id: String(datamate) }, + }) + + test("a relink makes the next turn load the new workspace's memory", async () => { // `hydrate` loads once per session. Relinking A -> B in an open session // otherwise kept injecting A's memory until a manual Refresh or a restart. const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") - listResponse = [ - { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, - ] + listResponse = [scoped("from-a", 42), scoped("from-b", 43)] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["from-a"]) const dir = mkdtempSync(path.join(SANDBOX, "relink-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 43, datamateName: "beta", projectPath: dir, linkedAt: 2 }) - expect(overlayBlocks(SES)).toEqual([]) + const b = { ...BINDING, datamateId: 43, datamateName: "beta", projectPath: dir, linkedAt: 2 } + workspaces = [...workspaces, { id: 43, name: "beta", memory_enabled: true }] + syncInternals.resolveBinding = async () => b as any + await recordApprovedBinding(dir, b) + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) + }) - listResponse = [ - { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, - ] + test("a binding discovered by the load itself does not discard that load", async () => { + // On a fresh clone the first load adopts the server binding, which notifies a + // change. Dropping the in-flight load there left the first turn with no memory. + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [scoped("first", 42)] + const dir = mkdtempSync(path.join(SANDBOX, "adopt-")) + syncInternals.resolveBinding = async () => { + await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { seed: false }) + return BINDING as any + } await hydrate(SES) - expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["from-b"]) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["first"]) }) }) describe("refresh racing a relink", () => { - test("a refresh that started before a relink does not write the old workspace back", async () => { + test("a refresh that overlaps a relink keeps its result and reloads on the next turn", async () => { listResponse = [ { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, ] @@ -1320,11 +1348,18 @@ describe("refresh racing a relink", () => { }) as typeof fetch const pending = refresh(SES) await new Promise((r) => setTimeout(r, 10)) - resetOverlay() // what a relink's binding-change notification does + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + const dir = mkdtempSync(path.join(SANDBOX, "race-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { seed: false }) release?.() const result = await pending - expect(result.ok).toBe(false) - expect(overlayBlocks(SES)).toEqual([]) + expect(result.ok).toBe(true) + globalThis.fetch = inner + listResponse = [ + { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) }) }) diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index 271f073783..5fdd14cb1b 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -459,6 +459,29 @@ describe("workspace skill sync", () => { } }) + test("a malformed pin retires the snapshot too", async () => { + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const pinEnv: Record = { ALTIMATE_CODE_SERVE: "1", ALTIMATE_PINNED_WORKSPACE_ID: "not-a-number" } + const keys = [...Object.keys(pinEnv), "ALTIMATE_PINNED_WORKSPACE_NAME", "ALTIMATE_PINNED_WORKSPACE_ROOT"] + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])) + delete process.env.ALTIMATE_PINNED_WORKSPACE_NAME + delete process.env.ALTIMATE_PINNED_WORKSPACE_ROOT + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + test("a pin leaves a snapshot outside its root alone", async () => { // The pin speaks for the folder the extension launched `serve` for; another // project's own snapshot is not its to take out of service. @@ -488,9 +511,9 @@ describe("workspace skill sync", () => { } }) - test("an unresolvable pin keeps a snapshot of the pinned workspace itself", async () => { - // A blip while pinned to the workspace the snapshot came from is not evidence - // of anything; same rule as an unpinned failed lookup. + test("an unresolvable pin retires even a snapshot of the pinned workspace", async () => { + // Unconfirmed is the same answer as revoked here: a blip after a successful + // validation resolves as a stale bound pin, not unknown, so unknown fails closed. serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) await syncSkills(project) const pinEnv: Record = { @@ -506,7 +529,7 @@ describe("workspace skill sync", () => { }) as unknown as typeof fetch try { await syncSkills(project) - expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) } finally { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k] diff --git a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts index 485ec9d4d4..176a10c934 100644 --- a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts @@ -226,7 +226,7 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () }) test("a pinned outcome always carries the routing caveat; a plain link never does", () => { - const caveat = "Skills, memory and warehouse tool routing follow this workspace" + const caveat = "and so does warehouse tool routing unless integrations are set to local" expect(render({ status: "bound", binding: binding(true) })).toContain(caveat) expect(render({ status: "bound", binding: binding(true), stale: true })).toContain(caveat) expect(render({ status: "bound", binding: binding(false) })).not.toContain(caveat) From a1342e778721ebb5b0afdd002f385bd637ce3995 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:34:53 -0700 Subject: [PATCH 03/19] fix(workspace): address second review round Hide a superseded memory overlay at once (epoch taken after binding resolution), so a relink, unlink or reset during a load or refresh cannot republish the old workspace; seed an offline Attach only after the server confirms the link; report the local memory opt-out distinctly; pin-aware no-account copy; latch the /workspace menu while the picker opens. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/identity.ts | 7 ++- .../src/altimate/workspace/memory-backfill.ts | 9 +++- .../src/altimate/workspace/memory-sync.ts | 50 ++++++++++++------- packages/opencode/src/cli/cmd/link.ts | 2 + .../src/plugin/tui/altimate/workspace.tsx | 9 +++- .../test/altimate/workspace/identity.test.ts | 7 +++ .../workspace/link-seed-message.test.ts | 1 + .../altimate/workspace/memory-sync.test.ts | 41 ++++++++++++++- 8 files changed, 102 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index a492d73b68..555e715de0 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -173,8 +173,11 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { "No Altimate account is connected, so whether this project is linked to an Altimate " + "Workspace cannot be checked.", `When ${TRIGGER}, say that, and that the user can connect an account with \`/connect\` ` + - "(Altimate AI) and then link this project with `altimate-code link`. Do not name a " + - "specific Altimate Workspace and do not say none is linked.", + "(Altimate AI)" + + (opts.pinned + ? ", after which the workspace selected in the IDE extension applies. " + : " and then link this project with `altimate-code link`. ") + + "Do not name a specific Altimate Workspace and do not say none is linked.", "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + "Databricks workspace) are unaffected and can be discussed normally.", ].join("\n") diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index 4338d9bb2e..41c26a68ae 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -19,7 +19,11 @@ const log = Log.create({ service: "altimate-workspace-memory-backfill" }) /** What a bind's memory seed concluded. `off` is "never ran" (memory disabled here or * for the workspace), `incomplete` is "ran and left blocks behind"; `link` reports the * two differently, since only the second needs the user to retry a Sync. */ -export type SeedOutcome = { status: "seeded" | "already" | "off" | "incomplete"; sent: number; pending: number } +export type SeedOutcome = { + status: "seeded" | "already" | "off" | "local-off" | "incomplete" + sent: number + pending: number +} /** Push every non-expired local block. Throttled and resumable inside * ``backfill`` — blocks already synced at their current payload are skipped, so @@ -29,7 +33,8 @@ export type SeedOutcome = { status: "seeded" | "already" | "off" | "incomplete"; * global blocks go up account-level. A bind is the only moment global memory is * swept; blocks written later ride the ordinary per-write mirror. */ export async function seedOnBind(directory: string, binding: CachedBinding): Promise { - if (!isEnabled()) return { status: "off", sent: 0, pending: 0 } + // Off on this machine (ALTIMATE_DISABLE_MEMORY), which is not the workspace's toggle. + if (!isEnabled()) return { status: "local-off", sent: 0, pending: 0 } try { // The directory and binding are passed in rather than rediscovered. The // `link` subcommand binds from a plain yargs handler with no instance diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 3dc4092e93..b5d2db220b 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -77,8 +77,11 @@ interface SessionMemory { waitTimedOut?: boolean /** The load has settled; a stale session is reloaded only then. */ settled?: boolean - /** The binding changed after this session's load started: reload on the next turn. */ - stale?: boolean + /** `bindingEpoch` as it stood once the committed load had resolved its binding. A later + * relink, unlink or reset moves the epoch past it: the overlay is then hidden at once and + * reloaded on the next hydrate. Taken after resolution, so a load that adopted its own + * binding (which notifies a change) is not mistaken for a stale one. */ + loadedEpoch?: number } const sessions = new Map() @@ -1073,7 +1076,7 @@ export async function hydrate(sessionID: string): Promise { // A relink since the last load: start over from the new binding. An in-flight // load is left to finish (it may be the one that discovered the binding) and // the reload happens on the turn after. - if (state.stale && state.settled) { + if (state.settled && state.loadedEpoch !== bindingEpoch) { sessions.delete(sessionID) state = sessionState(sessionID) } @@ -1125,21 +1128,23 @@ export async function whenHydrated( * "nothing to load" and "could not load" must stay distinguishable: collapsing * them is how a transient failure gets reported as a successful reload of an * empty workspace, taking the session's real memory with it. */ -type LoadOutcome = +type LoadOutcome = ( | { status: "loaded"; blocks: RemoteMemoryBlock[] } | { status: "unlinked" } | { status: "disabled" } | { status: "error" } +) & { epoch?: number } /** Read this project's workspace memory. Pure: it publishes nothing, so a slow * load that has been superseded cannot write over a newer result. */ async function loadWorkspaceMemory(directory?: string): Promise { try { const binding = await currentBinding(directory) - if (!binding) return { status: "unlinked" } + const epoch = bindingEpoch + if (!binding) return { status: "unlinked", epoch } const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error" } - if (enabled === "disabled") return { status: "disabled" } + if (enabled === "error") return { status: "error", epoch } + if (enabled === "disabled") return { status: "disabled", epoch } const ownProjectKey = projectKeyFor(binding) const ownWorkspace = String(binding.datamateId) @@ -1157,7 +1162,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { if (block.expires && new Date(block.expires) <= new Date()) continue blocks.push(block) } - return { status: "loaded", blocks } + return { status: "loaded", blocks, epoch } } catch (err) { log.warn("workspace memory load failed", { err: String(err) }) return { status: "error" } @@ -1170,6 +1175,11 @@ async function loadWorkspaceMemory(directory?: string): Promise { * an older in-flight load must not write into the newer one. */ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcome): void { if (sessions.get(sessionID) !== state) return + // Resolved against a binding that has since changed: publishing it would put the previous + // workspace's memory back. The next hydrate loads again. + if (outcome.epoch !== bindingEpoch) return + state.loadedEpoch = outcome.epoch + // An error keeps whatever the session had and is not retried every turn (as before). if (outcome.status === "error") return state.overlay = outcome.status === "loaded" ? outcome.blocks : [] if (outcome.status === "loaded" && outcome.blocks.length > 0) { @@ -1180,7 +1190,11 @@ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcom /** A session's cloud overlay. Returns a copy so a caller cannot mutate the * cached state in place. */ export function overlayBlocks(sessionID: string): RemoteMemoryBlock[] { - return [...(sessions.get(sessionID)?.overlay ?? [])] + const state = sessions.get(sessionID) + // Hidden as soon as the binding moves, not on the next turn: a tool call later in this + // turn must not read the previous workspace's memory. + if (!state || state.loadedEpoch !== bindingEpoch) return [] + return [...state.overlay] } export type RefreshResult = { @@ -1203,17 +1217,21 @@ export async function refresh(sessionID: string, directory?: string): Promise { const previous = overlayBlocks(sessionID) - const epoch = bindingEpoch + const previousEpoch = sessions.get(sessionID)?.loadedEpoch // `directory` is threaded through rather than resolved from the ambient // instance: the headless adapter this module serves has no instance, and // `manage.refresh(directory, sessionID)` promises the directory it was // given is the one that gets refreshed. const outcome = await loadWorkspaceMemory(directory) + // A relink, unlink or reset landed after this load resolved its binding: neither what it + // read nor what the session had before belongs to the current binding. + if (outcome.epoch !== bindingEpoch) return { count: 0, ok: false, status: "error" } if (outcome.status === "error") { // Keep what the session had. Emptying it because the network hiccuped is // strictly worse than not reloading, and the user asked for a reload. const state = sessionState(sessionID) state.overlay = previous + state.loadedEpoch = previousEpoch return { count: previous.length, ok: false, status: "error" } } // Replace the session's state so any older in-flight hydration is orphaned @@ -1222,9 +1240,6 @@ export async function refresh(sessionID: string, directory?: string): Promise { bindingEpoch++ - for (const state of sessions.values()) state.stale = true }) diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 82cd305a9e..dfd1e39e84 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -788,6 +788,8 @@ export function seedMessage(seed: SeedOutcome | null): string { : "Saved memory is in sync with the workspace." if (seed?.status === "already") return "Saved memory was already sent to this workspace." if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." + if (seed?.status === "local-off") + return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY), so saved memory stays here." // null: the seed could not run here (no resolvable credentials), which is not "off". return "Saved memory could not be checked against the workspace. Run /workspace → Sync in the TUI to retry." } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index f94a58305e..2dfa7bbc6d 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1266,7 +1266,12 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { driftedWas={hasDrift ? cachedIdent : undefined} manageUrl={manageUrl} unverified - onAttach={() => recordApprovedBinding(directory, local)} + // Seed only once the server confirms the cached link still stands: it may have been + // unlinked or rebound while the pre-check could not reach the service. + onAttach={async () => { + const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) + if (live?.datamate.id === local.datamateId) await recordApprovedBinding(directory, local) + }} /> )) return @@ -1879,6 +1884,8 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise return } if (option.value === "link") { + // Closed first so repeated Enter while the pre-check is slow cannot start more pickers. + api.ui.dialog.clear() runOnDemandPicker(api, directory).catch((err) => reportFlowFailure(api, err)) return } diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index bd393ed1b0..68b66cbab8 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -193,6 +193,13 @@ describe("unknown — link status could not be verified this turn", () => { ) }) + test("with no account under an IDE pin, points at the extension's selection, not at link", () => { + const out = render({ status: "unknown" }, undefined, { noAccount: true, pinned: true }) + expect(out).toContain("/connect") + expect(out).toContain("workspace selected in the IDE extension applies") + expect(out).not.toContain("altimate-code link") + }) + test("says no account is connected rather than 'could not be verified' when there is none", () => { const out = render({ status: "unknown" }, undefined, { noAccount: true }) expect(out).toContain("No Altimate account is connected") diff --git a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts index dd09772b16..c015d723d4 100644 --- a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts +++ b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts @@ -15,6 +15,7 @@ test("link reports a completed seed and memory that is off without a retry hint" "Workspace memory is off, so saved memory stays on this machine.", ) expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toBe("Saved memory was already sent to this workspace.") + expect(seedMessage({ status: "local-off", sent: 0, pending: 0 })).toContain("turned off on this machine") // A seed that could not run is not "memory is off". expect(seedMessage(null)).not.toContain("is off") expect(seedMessage(null)).toContain("Sync") diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 8e73afc346..7794077702 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1334,7 +1334,7 @@ describe("binding changes", () => { }) describe("refresh racing a relink", () => { - test("a refresh that overlaps a relink keeps its result and reloads on the next turn", async () => { + test("a refresh that overlaps a relink publishes nothing and the next turn loads the new binding", async () => { listResponse = [ { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, ] @@ -1353,7 +1353,9 @@ describe("refresh racing a relink", () => { await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { seed: false }) release?.() const result = await pending - expect(result.ok).toBe(true) + // Superseded: neither its read nor the prior overlay belongs to the new binding. + expect(result.ok).toBe(false) + expect(overlayBlocks(SES)).toEqual([]) globalThis.fetch = inner listResponse = [ { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, @@ -1363,6 +1365,41 @@ describe("refresh racing a relink", () => { }) }) +describe("overlay invalidation", () => { + test("a relink hides the previous workspace's memory at once, before the next hydrate", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).length).toBe(1) + const dir = mkdtempSync(path.join(SANDBOX, "hide-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, { seed: false }) + expect(overlayBlocks(SES)).toEqual([]) + }) + + test("an unlink reset during a refresh is not undone by the refresh", async () => { + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + const inner = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list")) await gate + return inner(input, init) + }) as typeof fetch + const pending = refresh(SES) + await new Promise((r) => setTimeout(r, 10)) + resetOverlay() // what Unlink does + release?.() + await pending + globalThis.fetch = inner + expect(overlayBlocks(SES)).toEqual([]) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per From 1539751e0e03b088075bda39feb0405e793f1c2b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:36:38 -0700 Subject: [PATCH 04/19] fix(workspace): clear the adopted flag when Attach approves a cached binding Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/plugin/tui/altimate/workspace.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 2dfa7bbc6d..82e428d2ae 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1270,7 +1270,9 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // unlinked or rebound while the pre-check could not reach the service. onAttach={async () => { const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) - if (live?.datamate.id === local.datamateId) await recordApprovedBinding(directory, local) + // Attach is the user's approval: the row is no longer merely adopted from the server. + if (live?.datamate.id === local.datamateId) + await recordApprovedBinding(directory, { ...local, adopted: false }) }} /> )) From da83923e7287b4a22942d30b99dbd89ff14863a3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:46:23 -0700 Subject: [PATCH 05/19] fix(workspace): scope memory epochs per project and bracket the binding lookup A relink landing mid-lookup can no longer stamp the old binding current, and linking another project no longer hides this one's memory. Offline Attach warns when it cannot confirm the link; pin advice only for a pin governing the folder; name both memory opt-out variables. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/identity.ts | 19 +++-- .../src/altimate/workspace/memory-sync.ts | 71 +++++++++++++------ .../opencode/src/altimate/workspace/state.ts | 21 ++++-- packages/opencode/src/cli/cmd/link.ts | 2 +- .../src/plugin/tui/altimate/workspace.tsx | 12 +++- .../workspace/identity-section.test.ts | 23 ++++++ .../altimate/workspace/memory-sync.test.ts | 49 ++++++++++++- 7 files changed, 159 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index 555e715de0..0ca81e34dc 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -24,7 +24,7 @@ // job — resolving "this/current/active workspace" is. import { createHash } from "node:crypto" import { onBindingChanged, readLocalBindingScoped, resolveBindingOutcome, type BindingOutcome } from "./state" -import { readPin } from "./pin" +import { readPin, resolveWithinRoot } from "./pin" import { workspaceLabel } from "./workspace-name" import { isEnabled } from "./engine-seams" import { Instance } from "../../project/instance" @@ -334,8 +334,15 @@ async function accountScope(): Promise { * model teammates will read a block that stays on this machine. The memo is * populated by the first enablement check of the session (the backfill sweep on * bind, or the first mirror), so the line appears from the next turn on. */ -function renderOptions(outcome: BindingOutcome): RenderOptions { - if (outcome.status === "unknown") return { pinned: readPin().kind !== "absent" } +/** A pin that actually governs this directory: valid and scoped to a root containing it. A + * malformed pin, or one for another folder, fails closed, so no advice may rely on it. */ +function governingPin(directory: string): boolean { + const pin = readPin() + return pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null +} + +function renderOptions(outcome: BindingOutcome, directory: string): RenderOptions { + if (outcome.status === "unknown") return { pinned: governingPin(directory) } if (outcome.status !== "bound") return {} // A stale outcome is "last known … may since have changed": promising that a // save syncs to that workspace would contradict the line above it. @@ -415,11 +422,11 @@ export async function systemSection(): Promise { if (!scope) return render({ status: "unknown" }, MAX_SECTION_CHARS, { noAccount: !(await AltimateApi.isConfigured().catch(() => false)), - pinned: readPin().kind !== "absent", + pinned: governingPin(directory), }) const key = keyFor(scope, directory) const hit = memo.get(key) - if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome)) + if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory)) // The fallback is itself raced against a small budget, so the wait is // bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk. const deadline = after(RESOLVE_DEADLINE_MS, () => @@ -427,7 +434,7 @@ export async function systemSection(): Promise { ) try { const outcome = await Promise.race([resolve(key, directory), deadline]) - return render(outcome, MAX_SECTION_CHARS, renderOptions(outcome)) + return render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory)) } finally { for (const t of timers) clearTimeout(t) } diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index b5d2db220b..8c5333e6a6 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -24,7 +24,7 @@ import { Log } from "@/altimate/util/log" import type { MemoryBlock } from "@/memory/types" import { TRAINING_META_COMMENT } from "@/altimate/training/types" // Aliased: `syncInternals.resolveBinding` below is an unrelated test seam. -import { onBindingChanged, resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" +import { canonicalDirectory, onBindingChanged, resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" import { WorkspaceApi } from "./api-client" import { AltimateApi } from "@/altimate/api/client" @@ -77,11 +77,12 @@ interface SessionMemory { waitTimedOut?: boolean /** The load has settled; a stale session is reloaded only then. */ settled?: boolean - /** `bindingEpoch` as it stood once the committed load had resolved its binding. A later - * relink, unlink or reset moves the epoch past it: the overlay is then hidden at once and - * reloaded on the next hydrate. Taken after resolution, so a load that adopted its own - * binding (which notifies a change) is not mistaken for a stale one. */ - loadedEpoch?: number + /** The project's binding epoch (see `epochFor`) under which the committed load resolved its + * binding. A later relink, unlink or reset of THIS project moves it on: the overlay is then + * hidden at once and reloaded on the next hydrate. */ + loadedEpoch?: string + /** Canonical directory the committed load was for; null when there was no instance. */ + dir?: string | null } const sessions = new Map() @@ -1076,7 +1077,7 @@ export async function hydrate(sessionID: string): Promise { // A relink since the last load: start over from the new binding. An in-flight // load is left to finish (it may be the one that discovered the binding) and // the reload happens on the turn after. - if (state.settled && state.loadedEpoch !== bindingEpoch) { + if (state.settled && state.loadedEpoch !== epochFor(state.dir ?? null)) { sessions.delete(sessionID) state = sessionState(sessionID) } @@ -1133,18 +1134,32 @@ type LoadOutcome = ( | { status: "unlinked" } | { status: "disabled" } | { status: "error" } -) & { epoch?: number } +) & { epoch?: string; dir?: string | null } /** Read this project's workspace memory. Pure: it publishes nothing, so a slow * load that has been superseded cannot write over a newer result. */ async function loadWorkspaceMemory(directory?: string): Promise { try { - const binding = await currentBinding(directory) - const epoch = bindingEpoch - if (!binding) return { status: "unlinked", epoch } + const raw = directory ?? currentDirectory() + const dir = raw ? canonicalDirectory(raw) : null + // The epoch must bracket the lookup: read only after it, a relink that lands while the + // lookup is pending would stamp the old binding as current. Read only before it, a lookup + // that adopts this project's server binding (which notifies a change) would stamp itself + // stale. So resolve until the epoch holds across one lookup; the retry is a cache read. + let binding: CachedBinding | null = null + let epoch = "" + let stable = false + for (let attempt = 0; attempt < 3 && !stable; attempt++) { + const before = epochFor(dir) + binding = await currentBinding(raw ?? undefined) + epoch = epochFor(dir) + stable = before === epoch + } + if (!stable) return { status: "error", epoch, dir } + if (!binding) return { status: "unlinked", epoch, dir } const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error", epoch } - if (enabled === "disabled") return { status: "disabled", epoch } + if (enabled === "error") return { status: "error", epoch, dir } + if (enabled === "disabled") return { status: "disabled", epoch, dir } const ownProjectKey = projectKeyFor(binding) const ownWorkspace = String(binding.datamateId) @@ -1162,7 +1177,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { if (block.expires && new Date(block.expires) <= new Date()) continue blocks.push(block) } - return { status: "loaded", blocks, epoch } + return { status: "loaded", blocks, epoch, dir } } catch (err) { log.warn("workspace memory load failed", { err: String(err) }) return { status: "error" } @@ -1177,8 +1192,9 @@ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcom if (sessions.get(sessionID) !== state) return // Resolved against a binding that has since changed: publishing it would put the previous // workspace's memory back. The next hydrate loads again. - if (outcome.epoch !== bindingEpoch) return + if (outcome.epoch !== epochFor(outcome.dir ?? null)) return state.loadedEpoch = outcome.epoch + state.dir = outcome.dir ?? null // An error keeps whatever the session had and is not retried every turn (as before). if (outcome.status === "error") return state.overlay = outcome.status === "loaded" ? outcome.blocks : [] @@ -1193,7 +1209,7 @@ export function overlayBlocks(sessionID: string): RemoteMemoryBlock[] { const state = sessions.get(sessionID) // Hidden as soon as the binding moves, not on the next turn: a tool call later in this // turn must not read the previous workspace's memory. - if (!state || state.loadedEpoch !== bindingEpoch) return [] + if (!state || state.loadedEpoch === undefined || state.loadedEpoch !== epochFor(state.dir ?? null)) return [] return [...state.overlay] } @@ -1218,6 +1234,7 @@ export async function refresh(sessionID: string, directory?: string): Promise { const previous = overlayBlocks(sessionID) const previousEpoch = sessions.get(sessionID)?.loadedEpoch + const previousDir = sessions.get(sessionID)?.dir // `directory` is threaded through rather than resolved from the ambient // instance: the headless adapter this module serves has no instance, and // `manage.refresh(directory, sessionID)` promises the directory it was @@ -1225,13 +1242,14 @@ export async function refresh(sessionID: string, directory?: string): Promise() + +function epochFor(dir: string | null): string { + return dir ? `${resetEpoch}:${directoryEpochs.get(dir) ?? 0}` : `${resetEpoch}:*${anyChangeEpoch}` +} /** Forget a session's hydration, or all of them. * @@ -1260,7 +1286,7 @@ export function resetOverlay(sessionID?: string): void { if (sessionID === undefined) { // Invalidate loads in flight too, or a pending refresh writes the cleared memory back // (Unlink resets while a Refresh may still be loading). - bindingEpoch++ + resetEpoch++ sessions.clear() // Both memos, not just the positive one. A refresh after memory was turned // ON for a workspace last seen off otherwise kept reporting zero unsynced @@ -1276,6 +1302,7 @@ export function resetOverlay(sessionID?: string): void { // session, and `hydrate` loads once per session: without this, a session that pulled // workspace A's memory keeps injecting it after the project is relinked to B. Loads record // the epoch after resolving their binding, so one that discovered its own binding is kept. -onBindingChanged(() => { - bindingEpoch++ +onBindingChanged((directory) => { + anyChangeEpoch++ + directoryEpochs.set(directory, (directoryEpochs.get(directory) ?? 0) + 1) }) diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 50b9c5d3a2..79b2b29361 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -192,6 +192,10 @@ function writeCache(cache: CacheFile): void { * (macOS ``/tmp`` → ``/private/tmp`` is the common case). Writers and readers * must both funnel through this or a shell-cwd write silently misses when the * TUI's canonicalized ``state.path.directory`` looks it back up. */ +export function canonicalDirectory(directory: string): string { + return canonicalizeKey(directory) +} + function canonicalizeKey(directory: string): string { try { return realpathSync(path.resolve(directory)) @@ -750,9 +754,11 @@ export async function resolveBindingOutcome(directory: string): Promise void>() +/** `directory` is the canonical path whose binding changed, so a listener can scope its + * reaction to that project rather than to every project the process serves. */ +const bindingChangeListeners = new Set<(directory: string) => void>() -export function onBindingChanged(listener: () => void): () => void { +export function onBindingChanged(listener: (directory: string) => void): () => void { bindingChangeListeners.add(listener) return () => { bindingChangeListeners.delete(listener) @@ -762,14 +768,15 @@ export function onBindingChanged(listener: () => void): () => void { /** Never throws: a listener is a UI refresh, and one bad subscriber must not * fail the link or unlink that notified it. Iterates a copy so a listener that * unsubscribes itself mid-notify cannot skip the next one. */ -function notifyBindingChanged(): void { +function notifyBindingChanged(directory: string): void { + const canonical = canonicalizeKey(directory) // Snapshot first: a listener may subscribe or unsubscribe while being // notified, and iterating the live Set would then walk a collection that // changed underneath us. const listeners = Array.from(bindingChangeListeners) for (const listener of listeners) { try { - listener() + listener(canonical) } catch (err) { log.warn("a binding-change listener threw", { err: String(err) }) } @@ -927,7 +934,7 @@ function forgetBinding( // milliseconds for as long as the state directory stays unwritable. A // read-only state directory now costs one poll interval of staleness // instead, which is the right trade. (Ralph, review of #1279.) - if (dropped) notifyBindingChanged() + if (dropped) notifyBindingChanged(directory) return true } @@ -1016,7 +1023,7 @@ async function lookupBinding( // sidebar is not always the caller — a `/workspace` open that adopts left // the tile to the next poll. Stamped as validated above, so the sidebar's // answering resolve trusts the row and does not come back here. - if (adoptedNow) notifyBindingChanged() + if (adoptedNow) notifyBindingChanged(directory) return { status: "bound", binding: adopted } } @@ -1148,7 +1155,7 @@ export async function recordApprovedBinding( // But the sidebar renders `datamateName`, so a rename is a visible change // with an unchanged identity. Checked separately for that reason. (cubic P2 // on #1279.) - if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged() + if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged(directory) // altimate_change start - seed the workspace with the memory this machine // already holds. Deliberately OUTSIDE the try above: a failed cache write diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index dfd1e39e84..8d12f1dd73 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -789,7 +789,7 @@ export function seedMessage(seed: SeedOutcome | null): string { if (seed?.status === "already") return "Saved memory was already sent to this workspace." if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." if (seed?.status === "local-off") - return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY), so saved memory stays here." + return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY or OPENCODE_DISABLE_MEMORY), so saved memory stays here." // null: the seed could not run here (no resolvable credentials), which is not "off". return "Saved memory could not be checked against the workspace. Run /workspace → Sync in the TUI to retry." } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 82e428d2ae..09dba5084f 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1271,8 +1271,18 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { onAttach={async () => { const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) // Attach is the user's approval: the row is no longer merely adopted from the server. - if (live?.datamate.id === local.datamateId) + if (live?.datamate.id === local.datamateId) { await recordApprovedBinding(directory, { ...local, adopted: false }) + return + } + api.ui.toast({ + variant: "warning", + message: + live === undefined + ? "Could not confirm the link with the workspace service, so saved memory was not sent. Try Attach again once it is reachable." + : "This project is no longer linked to that workspace, so nothing was attached. Run /workspace to see its current link.", + duration: 8_000, + }) }} /> )) diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index 3bc74cd499..d88ce149a6 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -818,6 +818,29 @@ describe("systemSection", () => { } }) + test("a malformed pin does not earn the IDE-selection advice", async () => { + const api = AltimateApi as unknown as { isConfigured: () => Promise } + const original = api.isConfigured + api.isConfigured = async () => false + const keys = ["ALTIMATE_CODE_SERVE", "ALTIMATE_PINNED_WORKSPACE_ID", "ALTIMATE_PINNED_WORKSPACE_NAME", "ALTIMATE_PINNED_WORKSPACE_ROOT"] + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])) + process.env.ALTIMATE_CODE_SERVE = "1" + process.env.ALTIMATE_PINNED_WORKSPACE_ID = "not-a-number" + delete process.env.ALTIMATE_PINNED_WORKSPACE_NAME + delete process.env.ALTIMATE_PINNED_WORKSPACE_ROOT + try { + const out = await inProject(systemSection) + expect(out).toContain("No Altimate account is connected") + expect(out).not.toContain("workspace selected in the IDE extension applies") + } finally { + api.isConfigured = original + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + test("a credentials file with an empty key renders unknown without invoking the resolver", async () => { // `accountScope` refuses the empty key, and nothing can verify a link without one — // so the resolver (whose own credential read still names a tenant and host, and diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 7794077702..f81a574b64 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1324,8 +1324,13 @@ describe("binding changes", () => { const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") listResponse = [scoped("first", 42)] const dir = mkdtempSync(path.join(SANDBOX, "adopt-")) + let adopted = false syncInternals.resolveBinding = async () => { - await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { seed: false }) + // The first lookup adopts (and notifies); later ones read the cache, as in production. + if (!adopted) { + adopted = true + await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { seed: false }) + } return BINDING as any } await hydrate(SES) @@ -1400,6 +1405,48 @@ describe("overlay invalidation", () => { }) }) +describe("epoch bracketing and scope", () => { + test("a relink that lands while the binding lookup is pending does not stamp the old binding current", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + const dir = mkdtempSync(path.join(SANDBOX, "midlookup-")) + let calls = 0 + syncInternals.resolveBinding = async () => { + calls++ + // First lookup returns A, but the relink to B lands before it returns. + if (calls === 1) { + await recordApprovedBinding(dir, { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, { seed: false }) + return BINDING as any + } + return { ...BINDING, datamateId: 46 } as any + } + workspaces = [...workspaces, { id: 46, name: "b", memory_enabled: true }] + await hydrate(SES) + // The retried lookup saw B; A's global record still belongs everywhere, but the load + // was stamped with B's epoch only after B resolved. + expect(calls).toBeGreaterThan(1) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-a"]) + }) + + test("linking another project does not hide this project's memory", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "mine", block_scope: "global" } }, + ] + const mine = mkdtempSync(path.join(SANDBOX, "mine-")) + const other = mkdtempSync(path.join(SANDBOX, "other-")) + await refresh(SES, mine) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) + await recordApprovedBinding(other, { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, { seed: false }) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) + // A change to this project's own binding still hides it. + await recordApprovedBinding(mine, { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, { seed: false }) + expect(overlayBlocks(SES)).toEqual([]) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per From 3090eaadfe0b02e581a502f16bdb7a75da81b26f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:48:07 -0700 Subject: [PATCH 06/19] fix(workspace): stamp failed memory loads; use the hidden-conflict check in the TUI A failed load no longer reloads on every turn; TUI conflict toasts use the same hidden-workspace predicate as the CLI; the already-seeded message no longer claims all memory is synced. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/memory-sync.ts | 8 +++++--- packages/opencode/src/cli/cmd/link.ts | 4 +++- .../src/plugin/tui/altimate/workspace.tsx | 19 ++++++++++--------- .../workspace/link-seed-message.test.ts | 4 +++- .../altimate/workspace/memory-sync.test.ts | 10 ++++++++++ 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 8c5333e6a6..6c54d9249b 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -1139,9 +1139,9 @@ type LoadOutcome = ( /** Read this project's workspace memory. Pure: it publishes nothing, so a slow * load that has been superseded cannot write over a newer result. */ async function loadWorkspaceMemory(directory?: string): Promise { + const raw = directory ?? currentDirectory() + const dir = raw ? canonicalDirectory(raw) : null try { - const raw = directory ?? currentDirectory() - const dir = raw ? canonicalDirectory(raw) : null // The epoch must bracket the lookup: read only after it, a relink that lands while the // lookup is pending would stamp the old binding as current. Read only before it, a lookup // that adopts this project's server binding (which notifies a change) would stamp itself @@ -1180,7 +1180,9 @@ async function loadWorkspaceMemory(directory?: string): Promise { return { status: "loaded", blocks, epoch, dir } } catch (err) { log.warn("workspace memory load failed", { err: String(err) }) - return { status: "error" } + // Stamped like any other outcome: without an epoch the session would reload (and make + // the prompt wait) on every turn for as long as the service is down. + return { status: "error", epoch: epochFor(dir), dir } } } diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 8d12f1dd73..43e28e4247 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -786,7 +786,9 @@ export function seedMessage(seed: SeedOutcome | null): string { return seed.sent > 0 ? `Sent ${seed.sent} saved memor${seed.sent === 1 ? "y" : "ies"} to the workspace.` : "Saved memory is in sync with the workspace." - if (seed?.status === "already") return "Saved memory was already sent to this workspace." + // `already` means the one-time bind seed ran before, not that every block is synced now. + if (seed?.status === "already") + return "This machine's saved memory was sent when this workspace was first linked. To resend anything missed since, run /workspace → Sync in the TUI." if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." if (seed?.status === "local-off") return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY or OPENCODE_DISABLE_MEMORY), so saved memory stays here." diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 09dba5084f..4e98fb16df 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -34,6 +34,7 @@ import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, HIDDEN_BINDING_MESSAGE, + isHiddenBindingConflict, ForbiddenError, NotFoundError, PreconditionFailedError, @@ -420,9 +421,9 @@ async function runBrowserHandoff( if (err instanceof ConflictError) { api.ui.toast({ variant: "warning", - message: err.detail.existing_datamate_name - ? `This project is already linked to "${err.detail.existing_datamate_name}". Run \`altimate-code link\` to change.` - : HIDDEN_BINDING_MESSAGE, + message: isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, }) } else if (err instanceof NotFoundError) { api.ui.toast({ @@ -899,9 +900,9 @@ function PickerDialog(props: PickerProps) { // The picker doesn't have a "Re-link" option; the referral used to // point at OfferDialog's Re-link, which doesn't exist either. Point // at the concrete next action instead. (kilo cycle 6.) - msg = err.detail.existing_datamate_name - ? `Already linked to "${err.detail.existing_datamate_name}". Re-run \`altimate-code link\` to change the workspace.` - : HIDDEN_BINDING_MESSAGE + msg = isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to change the workspace.` } else if (err instanceof PreconditionFailedError) { msg = "Someone else re-linked this project — reload and try again." } else if (err instanceof NotFoundError) { @@ -1096,9 +1097,9 @@ async function bindOrRebindInline( } catch (err) { let msg: string if (err instanceof ConflictError) { - msg = err.detail.existing_datamate_name - ? `Already linked to "${err.detail.existing_datamate_name}".` - : HIDDEN_BINDING_MESSAGE + msg = isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".` } else if (err instanceof PreconditionFailedError) { msg = "Someone else re-linked this project — reload and try again." } else if (err instanceof NotFoundError) { diff --git a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts index c015d723d4..ac8137df23 100644 --- a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts +++ b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts @@ -14,7 +14,9 @@ test("link reports a completed seed and memory that is off without a retry hint" expect(seedMessage({ status: "off", sent: 0, pending: 0 })).toBe( "Workspace memory is off, so saved memory stays on this machine.", ) - expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toBe("Saved memory was already sent to this workspace.") + // Does not claim everything is synced now: points at Sync for anything missed since. + expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toContain("when this workspace was first linked") + expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toContain("Sync") expect(seedMessage({ status: "local-off", sent: 0, pending: 0 })).toContain("turned off on this machine") // A seed that could not run is not "memory is off". expect(seedMessage(null)).not.toContain("is off") diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index f81a574b64..53b8d462af 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1447,6 +1447,16 @@ describe("epoch bracketing and scope", () => { }) }) +describe("hydration errors", () => { + test("a failed load is not retried on every turn", async () => { + listFails = true + await hydrate(SES) + await hydrate(SES) + await hydrate(SES) + expect(callsTo("/datamates/memory/list").length).toBe(1) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per From c67ec5243ba05dcae0f9dd7979862d92c98d62ea Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:55:59 -0700 Subject: [PATCH 07/19] fix(workspace): pin the account across offline Attach; keep a failed load's epoch Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/memory-sync.ts | 6 +++- .../src/plugin/tui/altimate/workspace.tsx | 19 ++++++++++++ .../altimate/workspace/memory-sync.test.ts | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 6c54d9249b..4ad0a6dd34 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -1141,6 +1141,9 @@ type LoadOutcome = ( async function loadWorkspaceMemory(directory?: string): Promise { const raw = directory ?? currentDirectory() const dir = raw ? canonicalDirectory(raw) : null + // Last epoch this load can vouch for. A failure after the binding resolved keeps that one, so + // an error from the previous workspace is not stamped as the new binding's settled load. + let vouched = epochFor(dir) try { // The epoch must bracket the lookup: read only after it, a relink that lands while the // lookup is pending would stamp the old binding as current. Read only before it, a lookup @@ -1155,6 +1158,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { epoch = epochFor(dir) stable = before === epoch } + vouched = epoch if (!stable) return { status: "error", epoch, dir } if (!binding) return { status: "unlinked", epoch, dir } const enabled = await memoryStatus(binding) @@ -1182,7 +1186,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { log.warn("workspace memory load failed", { err: String(err) }) // Stamped like any other outcome: without an epoch the session would reload (and make // the prompt wait) on every turn for as long as the service is down. - return { status: "error", epoch: epochFor(dir), dir } + return { status: "error", epoch: vouched, dir } } } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 4e98fb16df..b91777c4bb 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1152,6 +1152,13 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise< )) } +/** A digest of the full credential (URL, tenant and key), or null when none resolves. */ +async function attachAccount(): Promise { + const c = await AltimateApi.getCredentials().catch(() => null) + if (!c?.altimateApiKey || !c.altimateInstanceName || !c.altimateUrl) return null + return createHash("sha256").update(`${c.altimateUrl}|${c.altimateInstanceName}|${c.altimateApiKey}`).digest("hex") +} + async function runFlow(api: TuiPluginApi, directory: string): Promise { const identifier = resolveProjectIdentifier(directory) // Resolve latch scope ONCE — passed to isSkipActive here + threaded into @@ -1270,7 +1277,19 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // Seed only once the server confirms the cached link still stands: it may have been // unlinked or rebound while the pre-check could not reach the service. onAttach={async () => { + // The seed must run as the account that confirmed the link: the cache is scoped by + // tenant and URL only, so a key switch mid-dialog could otherwise confirm the id under + // one user and upload under another. + const who = await attachAccount() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) + if (who === null || (await attachAccount()) !== who) { + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) + return + } // Attach is the user's approval: the row is no longer merely adopted from the server. if (live?.datamate.id === local.datamateId) { await recordApprovedBinding(directory, { ...local, adopted: false }) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 53b8d462af..cfe72a347b 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1457,6 +1457,36 @@ describe("hydration errors", () => { }) }) +describe("superseded failures", () => { + test("a failed load for the old binding does not mark the new binding loaded", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + const inner = globalThis.fetch + let failList = true + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list") && failList) { + await gate + return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) + } + return inner(input, init) + }) as typeof fetch + const first = hydrate(SES) + await new Promise((r) => setTimeout(r, 10)) + const dir = mkdtempSync(path.join(SANDBOX, "supersede-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 49, projectPath: dir, linkedAt: 9 }, { seed: false }) + release?.() + await first + failList = false + globalThis.fetch = inner + listResponse = [ + { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per From 15ecfca00e01960111fac9371a38a7293644c3e7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 01:57:01 -0700 Subject: [PATCH 08/19] fix(workspace): pin offline Attach to the account the dialog was shown under Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/plugin/tui/altimate/workspace.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index b91777c4bb..eef243d2bf 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1263,6 +1263,8 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) + // The cache row is scoped to the account that was current here; Attach must still be it. + const shownAs = await attachAccount() api.ui.dialog.replace(() => ( { // one user and upload under another. const who = await attachAccount() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) - if (who === null || (await attachAccount()) !== who) { + if (who === null || who !== shownAs || (await attachAccount()) !== who) { api.ui.toast({ variant: "warning", message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", From 2ede9c7ba8aec5e7bd85f9adf744c487d20bc416 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:05:17 -0700 Subject: [PATCH 09/19] fix(workspace): pin the account inside recordApprovedBinding; harden the relink test Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/workspace/state.ts | 21 +++++++++++++++++-- .../src/plugin/tui/altimate/workspace.tsx | 17 +++++---------- .../test/altimate/plugin/workspace.test.ts | 11 ++++++++++ .../altimate/workspace/memory-sync.test.ts | 19 ++++++++++++----- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 79b2b29361..fcbf3e8449 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -1090,15 +1090,28 @@ export async function currentScope(): Promise<{ tenant: string; apiUrl: string } return tenantKey() } +/** A digest of the full credential (URL, tenant and API key), or null when none resolves. + * The binding cache is scoped by tenant and URL only, so a same-tenant key switch needs this. */ +export async function accountDigest(): Promise { + const c = await AltimateApi.getCredentials().catch(() => null) + if (!c?.altimateApiKey || !c.altimateInstanceName || !c.altimateUrl) return null + return createHash("sha256").update(`${c.altimateUrl}|${c.altimateInstanceName}|${c.altimateApiKey}`).digest("hex") +} + export async function recordApprovedBinding( directory: string, binding: CachedBinding, // `seed: false` warms the cache for a link the user has not accepted yet: a discovered - // link must not upload local memory before they choose Attach. - opts?: { awaitBackfill?: boolean; seed?: boolean }, + // link must not upload local memory before they choose Attach. `account` (from + // `accountDigest`) pins the write and the seed to the credential that confirmed the link. + opts?: { awaitBackfill?: boolean; seed?: boolean; account?: string }, ): Promise { const key = await tenantKey() if (!key) return null + if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { + log.warn("the Altimate account changed before the link was recorded; not recording it") + return null + } // An explicit link is the newest word on this project, so retire any memoized // "no binding here" from before it and count the row as server-validated — // the link is what created it. Without the first, revalidation reads the @@ -1186,6 +1199,10 @@ export async function recordApprovedBinding( // machine already holds never reach the workspace. if (alreadySeeded) return { status: "already", sent: 0, pending: 0 } if (opts?.seed === false) return null + if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { + log.warn("the Altimate account changed before the memory seed; not seeding") + return null + } const seeded = import("./memory-backfill") .then((m) => m.seedOnBind(canonicalizeKey(directory), binding)) .then((outcome) => { diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index eef243d2bf..beeed9b14a 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -56,7 +56,7 @@ import { projectNameFromRemote, resolveProjectIdentifier, } from "@/altimate/workspace/detect" -import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { accountDigest, readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" import { describeOffer, installCommand, @@ -1152,13 +1152,6 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise< )) } -/** A digest of the full credential (URL, tenant and key), or null when none resolves. */ -async function attachAccount(): Promise { - const c = await AltimateApi.getCredentials().catch(() => null) - if (!c?.altimateApiKey || !c.altimateInstanceName || !c.altimateUrl) return null - return createHash("sha256").update(`${c.altimateUrl}|${c.altimateInstanceName}|${c.altimateApiKey}`).digest("hex") -} - async function runFlow(api: TuiPluginApi, directory: string): Promise { const identifier = resolveProjectIdentifier(directory) // Resolve latch scope ONCE — passed to isSkipActive here + threaded into @@ -1264,7 +1257,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) // The cache row is scoped to the account that was current here; Attach must still be it. - const shownAs = await attachAccount() + const shownAs = await accountDigest() api.ui.dialog.replace(() => ( { // The seed must run as the account that confirmed the link: the cache is scoped by // tenant and URL only, so a key switch mid-dialog could otherwise confirm the id under // one user and upload under another. - const who = await attachAccount() + const who = await accountDigest() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) - if (who === null || who !== shownAs || (await attachAccount()) !== who) { + if (who === null || who !== shownAs || (await accountDigest()) !== who) { api.ui.toast({ variant: "warning", message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", @@ -1294,7 +1287,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { } // Attach is the user's approval: the row is no longer merely adopted from the server. if (live?.datamate.id === local.datamateId) { - await recordApprovedBinding(directory, { ...local, adopted: false }) + await recordApprovedBinding(directory, { ...local, adopted: false }, { account: who }) return } api.ui.toast({ diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 3819c89632..0f21196a1d 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -351,6 +351,17 @@ describe("workspace binding cache", () => { } }) + test("an account mismatch refuses to record the link or seed", async () => { + // Attach pins the credential that confirmed the link; a switch before the write must not + // record the row, or upload this machine's memory, under another account. + const proj = path.join(SANDBOX, "account-pinned") + mkdirSync(proj, { recursive: true }) + const binding = { datamateId: 12, datamateName: "Pinned", repoRemote: null, projectPath: proj, linkedAt: 1 } + const out = await recordApprovedBinding(proj, binding, { awaitBackfill: true, account: "not-the-current-account" }) + expect(out).toBeNull() + expect(await readLocalBinding(proj)).toBeNull() + }) + test("a warm bind still syncs skills even though the memory seed is skipped", async () => { // The ``alreadySeeded`` marker is memory's one-shot gate. Skills have a // different lifecycle — the workspace's bundles can change at any time — so diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index cfe72a347b..f898c3db21 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1460,28 +1460,37 @@ describe("hydration errors", () => { describe("superseded failures", () => { test("a failed load for the old binding does not mark the new binding loaded", async () => { const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + const scoped = (id: string, datamate: number) => ({ + id, + memory: id, + metadata: { source: MIRROR_SOURCE, block_id: id, block_scope: "project", datamate_id: String(datamate) }, + }) + const b = { ...BINDING, datamateId: 49 } + workspaces = [...workspaces, { id: 49, name: "b", memory_enabled: true }] let release: (() => void) | undefined const gate = new Promise((r) => (release = r)) + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) const inner = globalThis.fetch let failList = true globalThis.fetch = (async (input: any, init?: any) => { if (String(input).includes("/datamates/memory/list") && failList) { + entered?.() // A's binding has resolved and its list request is now in flight await gate return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) } return inner(input, init) }) as typeof fetch const first = hydrate(SES) - await new Promise((r) => setTimeout(r, 10)) + await reachedList const dir = mkdtempSync(path.join(SANDBOX, "supersede-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 49, projectPath: dir, linkedAt: 9 }, { seed: false }) + syncInternals.resolveBinding = async () => b as any + await recordApprovedBinding(dir, { ...b, projectPath: dir, linkedAt: 9 }, { seed: false }) release?.() await first failList = false globalThis.fetch = inner - listResponse = [ - { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, - ] + listResponse = [scoped("from-a", 42), scoped("from-b", 49)] await hydrate(SES) expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) }) From 0d54c65b19f366c91b3b7ac559df23c5d78ba25c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:07:18 -0700 Subject: [PATCH 10/19] fix(workspace): revalidate online Attach; Open follows the IDE pin; signal-based race tests Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/plugin/tui/altimate/workspace.tsx | 35 +++++++++++++++++-- .../altimate/workspace/memory-sync.test.ts | 18 +++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index beeed9b14a..c3cd58aeb2 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -56,7 +56,12 @@ import { projectNameFromRemote, resolveProjectIdentifier, } from "@/altimate/workspace/detect" -import { accountDigest, readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { + accountDigest, + readLocalBinding, + recordApprovedBinding, + resolvePinnedBindingForRouting, +} from "@/altimate/workspace/state" import { describeOffer, installCommand, @@ -1215,6 +1220,8 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // Resolved before the dialog renders — see AlreadyLinkedDialog's comment // on why this can't be fetched async inside the dialog itself. const manageUrl = await resolveManageUrl(serverBinding.datamate.id) + // Same pinning as the offline branch: Attach may come long after this pre-check. + const discoveredAs = await accountDigest() api.ui.dialog.replace(() => ( { hasDrift={hasDrift} driftedWas={hasDrift ? boundIdent : undefined} manageUrl={manageUrl} - onAttach={() => recordApprovedBinding(directory, discovered)} + onAttach={async () => { + // Re-confirm before seeding: the account or the project's link can change between the + // pre-check and the click, and the seed must go to the link that still stands. + const who = await accountDigest() + const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) + if (who !== null && who === discoveredAs && live?.datamate.id === discovered.datamateId) { + await recordApprovedBinding(directory, discovered, { account: who }) + return + } + api.ui.toast({ + variant: "warning", + message: + who !== discoveredAs + ? "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again." + : live === undefined + ? "Could not confirm the link with the workspace service, so saved memory was not sent. Try Attach again once it is reachable." + : "This project is no longer linked to that workspace, so nothing was attached. Run /workspace to see its current link.", + duration: 8_000, + }) + }} /> )) return @@ -1870,7 +1896,10 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise const linked = report.binding !== null // Resolved before render, like AlreadyLinkedDialog's: an option appearing after // paint would shift the row under the user's cursor. - const manageUrl = report.binding ? await resolveManageUrl(report.binding.datamateId) : null + // Under an IDE pin, skills, memory and routing follow the pinned workspace, so Open must too. + const pinned = await resolvePinnedBindingForRouting(directory).catch(() => null) + const openId = pinned?.status === "bound" ? pinned.binding.datamateId : report.binding?.datamateId + const manageUrl = openId !== undefined ? await resolveManageUrl(openId) : null api.ui.dialog.replace(() => ( { let release: (() => void) | undefined const gate = new Promise((r) => (release = r)) const inner = globalThis.fetch + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) globalThis.fetch = (async (input: any, init?: any) => { - if (String(input).includes("/datamates/memory/list")) await gate + if (String(input).includes("/datamates/memory/list")) { + entered?.() + await gate + } return inner(input, init) }) as typeof fetch const pending = refresh(SES) - await new Promise((r) => setTimeout(r, 10)) + await reachedList const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") const dir = mkdtempSync(path.join(SANDBOX, "race-")) await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { seed: false }) @@ -1391,12 +1396,17 @@ describe("overlay invalidation", () => { let release: (() => void) | undefined const gate = new Promise((r) => (release = r)) const inner = globalThis.fetch + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) globalThis.fetch = (async (input: any, init?: any) => { - if (String(input).includes("/datamates/memory/list")) await gate + if (String(input).includes("/datamates/memory/list")) { + entered?.() + await gate + } return inner(input, init) }) as typeof fetch const pending = refresh(SES) - await new Promise((r) => setTimeout(r, 10)) + await reachedList resetOverlay() // what Unlink does release?.() await pending From 1349da225b9d9eb758b25145fde3a74702a57a3a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:14:17 -0700 Subject: [PATCH 11/19] fix(workspace): capture the account before the pre-check; Open fails closed on a bad pin Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/plugin/tui/altimate/workspace.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index c3cd58aeb2..c7676af2ca 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1158,6 +1158,9 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise< } async function runFlow(api: TuiPluginApi, directory: string): Promise { + // Before any lookup: Attach must run as the account the dialog's binding was found under, and + // a switch while the pre-check is in flight would otherwise go unnoticed. + const flowAccount = await accountDigest() const identifier = resolveProjectIdentifier(directory) // Resolve latch scope ONCE — passed to isSkipActive here + threaded into // OfferDialog so its sync onSelect can call recordSkip without awaiting. @@ -1220,8 +1223,6 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // Resolved before the dialog renders — see AlreadyLinkedDialog's comment // on why this can't be fetched async inside the dialog itself. const manageUrl = await resolveManageUrl(serverBinding.datamate.id) - // Same pinning as the offline branch: Attach may come long after this pre-check. - const discoveredAs = await accountDigest() api.ui.dialog.replace(() => ( { // pre-check and the click, and the seed must go to the link that still stands. const who = await accountDigest() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) - if (who !== null && who === discoveredAs && live?.datamate.id === discovered.datamateId) { + if (who !== null && who === flowAccount && live?.datamate.id === discovered.datamateId) { await recordApprovedBinding(directory, discovered, { account: who }) return } api.ui.toast({ variant: "warning", message: - who !== discoveredAs + who !== flowAccount ? "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again." : live === undefined ? "Could not confirm the link with the workspace service, so saved memory was not sent. Try Attach again once it is reachable." @@ -1282,8 +1283,6 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) - // The cache row is scoped to the account that was current here; Attach must still be it. - const shownAs = await accountDigest() api.ui.dialog.replace(() => ( { // one user and upload under another. const who = await accountDigest() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) - if (who === null || who !== shownAs || (await accountDigest()) !== who) { + if (who === null || who !== flowAccount || (await accountDigest()) !== who) { api.ui.toast({ variant: "warning", message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", @@ -1898,7 +1897,13 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise // paint would shift the row under the user's cursor. // Under an IDE pin, skills, memory and routing follow the pinned workspace, so Open must too. const pinned = await resolvePinnedBindingForRouting(directory).catch(() => null) - const openId = pinned?.status === "bound" ? pinned.binding.datamateId : report.binding?.datamateId + // A pin that cannot be honoured fails closed everywhere else; Open must not fall through to + // the project's own link either. + const openId = pinned + ? pinned.status === "bound" + ? pinned.binding.datamateId + : undefined + : report.binding?.datamateId const manageUrl = openId !== undefined ? await resolveManageUrl(openId) : null api.ui.dialog.replace(() => ( From dd92f912ea86c1907fe1faa8294f95fa21783bf9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:26:35 -0700 Subject: [PATCH 12/19] fix(workspace): pin the account when warming a discovered binding Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/plugin/tui/altimate/workspace.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index c7676af2ca..84056a0351 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1206,7 +1206,9 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // Warm the local cache so an offline follow-up render is consistent. // altimate_change start — no memory seed until the user picks Attach: this link may be a // teammate's, and opening the TUI must not upload this machine's memory to it. - await recordApprovedBinding(directory, discovered, { seed: false }) + // Pinned to the account the pre-check ran as: a switch mid-lookup must not write this + // binding (or start its skill sync) under the other account. + await recordApprovedBinding(directory, discovered, { seed: false, account: flowAccount ?? undefined }) // altimate_change end // Drift = the identifier the server matched on doesn't equal the // corresponding identifier this project currently has. E.g. we matched From c0143bd47b6bf8fd6e89b81ece2d6585c4b196d9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:33:18 -0700 Subject: [PATCH 13/19] fix(workspace): skip the discovery warm-up when no account is known Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/plugin/tui/altimate/workspace.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 84056a0351..87e5b2156a 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1207,8 +1207,9 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // altimate_change start — no memory seed until the user picks Attach: this link may be a // teammate's, and opening the TUI must not upload this machine's memory to it. // Pinned to the account the pre-check ran as: a switch mid-lookup must not write this - // binding (or start its skill sync) under the other account. - await recordApprovedBinding(directory, discovered, { seed: false, account: flowAccount ?? undefined }) + // binding (or start its skill sync) under the other account. With no account known at the + // start there is nothing to pin to, so the warm-up is skipped rather than left unguarded. + if (flowAccount !== null) await recordApprovedBinding(directory, discovered, { seed: false, account: flowAccount }) // altimate_change end // Drift = the identifier the server matched on doesn't equal the // corresponding identifier this project currently has. E.g. we matched From ab01381ede21f2693f2da86754a529e3b3769675 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:34:11 -0700 Subject: [PATCH 14/19] fix(workspace): keep a failed pin check unresolved for Open in browser Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/plugin/tui/altimate/workspace.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 87e5b2156a..806fca1d71 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1899,7 +1899,9 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise // Resolved before render, like AlreadyLinkedDialog's: an option appearing after // paint would shift the row under the user's cursor. // Under an IDE pin, skills, memory and routing follow the pinned workspace, so Open must too. - const pinned = await resolvePinnedBindingForRouting(directory).catch(() => null) + // It only returns early (null) when there is no pin, so a throw means a pin exists but could + // not be checked: keep it unresolved rather than falling through to the project's link. + const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) // A pin that cannot be honoured fails closed everywhere else; Open must not fall through to // the project's own link either. const openId = pinned From 62ba1384aaea9ad93ec96ebd89e566f8be697ba3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 02:57:58 -0700 Subject: [PATCH 15/19] fix(workspace): report the seed gate from the sweep itself; Open for an unlinked pinned project Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/memory-backfill.ts | 6 ++++-- .../src/altimate/workspace/memory-sync.ts | 20 +++++++++++++++---- .../src/plugin/tui/altimate/workspace.tsx | 4 ++++ .../altimate/workspace/memory-sync.test.ts | 13 ++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index 41c26a68ae..32a6ef50b1 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -11,7 +11,7 @@ // reaches this through a lazy dynamic import instead. import { MemoryStore } from "@/memory/store" import { Log } from "@/altimate/util/log" -import { backfill, isEnabled, memoryEnabledCached } from "./memory-sync" +import { backfill, isEnabled } from "./memory-sync" import type { CachedBinding } from "./state" const log = Log.create({ service: "altimate-workspace-memory-backfill" }) @@ -46,8 +46,10 @@ export async function seedOnBind(directory: string, binding: CachedBinding): Pro const result = await backfill(blocks, binding, directory) log.info("workspace memory seeded after bind", result) // `gated` also covers a failed enablement lookup; only a confirmed toggle is "off". + // The sweep's own gate result, not the cache: a stale "disabled" memo beside a failed + // lookup is still an unknown state, not memory off. if (result.gated) - return memoryEnabledCached(binding) === "disabled" + return result.gateReason === "disabled" ? { status: "off", sent: 0, pending: 0 } : { status: "incomplete", sent: 0, pending: blocks.length } // Only a sweep that stored everything it meant to counts as seeded. A diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 4ad0a6dd34..0e9e8c7c08 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -954,16 +954,28 @@ export async function backfill( blocks: MemoryBlock[], explicitBinding?: CachedBinding, sweepDirectory?: string, -): Promise<{ ok: number; failed: number; skipped: number; declined: number; deferred: number; gated: boolean }> { +): Promise<{ + ok: number + failed: number + skipped: number + declined: number + deferred: number + gated: boolean + /** Why a gated sweep never ran; only "disabled" is a confirmed workspace toggle. */ + gateReason?: "local-off" | "unbound" | "disabled" | "error" +}> { // ``gated`` says the sweep never ran, as opposed to running and storing // nothing. A caller recording "this binding is seeded" must be able to tell // those apart: memory being off is not a completed seed. - if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true } + if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } // The bind path passes the binding it just recorded; there is no ambient // instance to resolve one from on the `link` subcommand. const binding = explicitBinding ?? (await currentBinding()) - if (!binding || !(await memoryEnabled(binding))) - return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true } + if (!binding) + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: "unbound" } + const status = await memoryStatus(binding) + if (status !== "enabled") + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: status } const index = await readIndex() const { pending, skipped } = partitionPending(blocks, binding, index) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 806fca1d71..27ea4beac9 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1940,6 +1940,10 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise value: "link", description: "Pick an existing workspace or create one for this project.", }, + // An IDE pin can govern a project that has no link of its own. + ...(manageUrl + ? [{ title: "Open in browser", value: "open", description: "View the pinned workspace on the web." }] + : []), { title: "Done", value: "done", description: "Close this menu." }, ] } diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index cee2da3a52..3a5dbe1967 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1278,6 +1278,19 @@ describe("truncated reads", () => { const failed = await seedOnBind(dir, BINDING as any) globalThis.fetch = blip expect(failed.status).toBe("incomplete") + + // A stale "disabled" memo beside a failed fresh lookup is still unknown, not off. + resetOverlay() + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + expect((await seedOnBind(dir, BINDING as any)).status).toBe("off") // memo now says disabled + const blip2 = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => + String(input).includes("/datamates/") && !String(input).includes("/memory") + ? new Response("{}", { status: 503 }) + : blip2(input, init)) as typeof fetch + const stale = await seedOnBind(dir, BINDING as any) + globalThis.fetch = blip2 + expect(["off", "incomplete"]).toContain(stale.status) }) }) From 1b1dfd6988d24999478e07280f0044382627b46f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 03:06:18 -0700 Subject: [PATCH 16/19] fix(workspace): report an unavailable memory setting in Sync; pin the stale-memo test Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/workspace/manage.ts | 16 ++++++++++++---- .../src/plugin/tui/altimate/workspace.tsx | 5 ++++- .../test/altimate/workspace/manage.test.ts | 19 +++++++++++++++++++ .../altimate/workspace/memory-sync.test.ts | 2 +- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index 1da4632339..2a5408c61f 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -82,7 +82,7 @@ export interface SyncReport { * and only one of them is the workspace's memory toggle; a toast that said * "memory is off" for a failed local read sent the user to a setting that was * fine. */ - gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" + gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" | "setting-unavailable" sent: number failed: number /** Already present in the workspace at their current payload. */ @@ -253,9 +253,17 @@ export async function sync(directory: string): Promise { const result = await MemorySync.backfill(blocks, binding, directory) return { gated: result.gated, - // `backfill` gates on exactly one thing this far in: the workspace's own - // setting. The flag and the binding were checked above. - gatedBecause: result.gated ? "memory-off" : undefined, + // The sweep reports why it did not run: only a confirmed toggle is memory-off; a failed + // enablement lookup is a transient "setting-unavailable", not a disabled workspace. + gatedBecause: !result.gated + ? undefined + : result.gateReason === "disabled" + ? "memory-off" + : result.gateReason === "local-off" + ? "flag-off" + : result.gateReason === "unbound" + ? "no-binding" + : "setting-unavailable", sent: result.ok, failed: result.failed, skipped: result.skipped, diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 27ea4beac9..9a8b9e5b29 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1857,7 +1857,8 @@ export { syncMessage as syncMessageForTests } * project's local memory" as the text. A failed read is a warning; the other * gates are states, not outcomes, and are told as information. */ function syncVariant(result: Manage.SyncReport): "info" | "success" | "warning" { - if (result.gated) return result.gatedBecause === "read-failed" ? "warning" : "info" + if (result.gated) + return result.gatedBecause === "read-failed" || result.gatedBecause === "setting-unavailable" ? "warning" : "info" return result.failed > 0 || result.declined > 0 || result.deferred > 0 ? "warning" : "success" } export { syncVariant as syncVariantForTests } @@ -1873,6 +1874,8 @@ function syncMessage(result: Manage.SyncReport): string { return "Nothing to sync — the pinned workspace could not be confirmed for this project." case "flag-off": return "Nothing to sync — workspace memory is not enabled in this build." + case "setting-unavailable": + return "Could not check the workspace's memory setting, so nothing was synced. Try Sync again shortly." default: return "Nothing to sync — workspace memory is off for this project." } diff --git a/packages/opencode/test/altimate/workspace/manage.test.ts b/packages/opencode/test/altimate/workspace/manage.test.ts index 49eb74e41d..cb5c1131fc 100644 --- a/packages/opencode/test/altimate/workspace/manage.test.ts +++ b/packages/opencode/test/altimate/workspace/manage.test.ts @@ -817,6 +817,25 @@ describe("status and the sweep must agree", () => { expect(result.gated).toBe(true) expect(result.sent).toBe(0) }) + + test("a failed memory-setting lookup is reported as unavailable, not as memory off", async () => { + // Both gate the sweep, but only a confirmed toggle means the workspace has memory off; + // telling the user so during an outage sends them to a setting that is fine. + await bind(projectDir) + const originalFetch3 = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === "string" ? input : input.url + if (url.includes("/datamates/") && !url.includes("/memory")) return new Response("{}", { status: 503 }) + return originalFetch3(input, init) + }) as typeof fetch + try { + const result = await sync(projectDir) + expect(result.gated).toBe(true) + expect(result.gatedBecause).toBe("setting-unavailable") + } finally { + globalThis.fetch = originalFetch3 + } + }) }) describe("what /workspace status may cost and claim (review round 2)", () => { diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 3a5dbe1967..008151e873 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1290,7 +1290,7 @@ describe("truncated reads", () => { : blip2(input, init)) as typeof fetch const stale = await seedOnBind(dir, BINDING as any) globalThis.fetch = blip2 - expect(["off", "incomplete"]).toContain(stale.status) + expect(stale.status).toBe("incomplete") }) }) From 39dd90166d82b12e96bae22f583341c4daf1cf2f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 03:08:10 -0700 Subject: [PATCH 17/19] fix(workspace): surface an account-changed Attach; do not overstate unsent memory on gate errors Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/altimate/workspace/memory-backfill.ts | 5 +++-- .../opencode/src/altimate/workspace/state.ts | 4 ++-- packages/opencode/src/cli/cmd/link.ts | 2 ++ .../src/plugin/tui/altimate/workspace.tsx | 16 ++++++++++++++-- .../test/altimate/plugin/workspace.test.ts | 2 +- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index 32a6ef50b1..fb4da92873 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -20,7 +20,7 @@ const log = Log.create({ service: "altimate-workspace-memory-backfill" }) * for the workspace), `incomplete` is "ran and left blocks behind"; `link` reports the * two differently, since only the second needs the user to retry a Sync. */ export type SeedOutcome = { - status: "seeded" | "already" | "off" | "local-off" | "incomplete" + status: "seeded" | "already" | "off" | "local-off" | "incomplete" | "account-changed" sent: number pending: number } @@ -51,7 +51,8 @@ export async function seedOnBind(directory: string, binding: CachedBinding): Pro if (result.gated) return result.gateReason === "disabled" ? { status: "off", sent: 0, pending: 0 } - : { status: "incomplete", sent: 0, pending: blocks.length } + : // How many are really unsent is unknown (some may be indexed from an earlier seed). + { status: "incomplete", sent: 0, pending: 0 } // Only a sweep that stored everything it meant to counts as seeded. A // failure here must leave the binding eligible for a retry, or local blocks // stay absent from the workspace until a rebind or an unrelated edit. diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index fcbf3e8449..cae7fbee87 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -1110,7 +1110,7 @@ export async function recordApprovedBinding( if (!key) return null if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { log.warn("the Altimate account changed before the link was recorded; not recording it") - return null + return { status: "account-changed", sent: 0, pending: 0 } } // An explicit link is the newest word on this project, so retire any memoized // "no binding here" from before it and count the row as server-validated — @@ -1201,7 +1201,7 @@ export async function recordApprovedBinding( if (opts?.seed === false) return null if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { log.warn("the Altimate account changed before the memory seed; not seeding") - return null + return { status: "account-changed", sent: 0, pending: 0 } } const seeded = import("./memory-backfill") .then((m) => m.seedOnBind(canonicalizeKey(directory), binding)) diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 43e28e4247..20a6cebd1d 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -790,6 +790,8 @@ export function seedMessage(seed: SeedOutcome | null): string { if (seed?.status === "already") return "This machine's saved memory was sent when this workspace was first linked. To resend anything missed since, run /workspace → Sync in the TUI." if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." + if (seed?.status === "account-changed") + return "Your Altimate account changed during linking, so saved memory was not sent. Run /workspace → Sync in the TUI to retry." if (seed?.status === "local-off") return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY or OPENCODE_DISABLE_MEMORY), so saved memory stays here." // null: the seed could not run here (no resolvable credentials), which is not "off". diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 9a8b9e5b29..45f3f12796 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1242,7 +1242,13 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { const who = await accountDigest() const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) if (who !== null && who === flowAccount && live?.datamate.id === discovered.datamateId) { - await recordApprovedBinding(directory, discovered, { account: who }) + const out = await recordApprovedBinding(directory, discovered, { account: who }) + if (out?.status === "account-changed") + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) return } api.ui.toast({ @@ -1315,7 +1321,13 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { } // Attach is the user's approval: the row is no longer merely adopted from the server. if (live?.datamate.id === local.datamateId) { - await recordApprovedBinding(directory, { ...local, adopted: false }, { account: who }) + const out = await recordApprovedBinding(directory, { ...local, adopted: false }, { account: who }) + if (out?.status === "account-changed") + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) return } api.ui.toast({ diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 0f21196a1d..121e8f09e2 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -358,7 +358,7 @@ describe("workspace binding cache", () => { mkdirSync(proj, { recursive: true }) const binding = { datamateId: 12, datamateName: "Pinned", repoRemote: null, projectPath: proj, linkedAt: 1 } const out = await recordApprovedBinding(proj, binding, { awaitBackfill: true, account: "not-the-current-account" }) - expect(out).toBeNull() + expect(out?.status).toBe("account-changed") expect(await readLocalBinding(proj)).toBeNull() }) From 010b87c4c5e0a19843ae4d25f582da83d8ee115a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 03:21:38 -0700 Subject: [PATCH 18/19] fix(workspace): pin the account in link; bound the menu pin check; no Switch under a pin Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/cli/cmd/link.ts | 14 ++++++--- .../src/plugin/tui/altimate/workspace.tsx | 29 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 20a6cebd1d..a74d4fb599 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -41,7 +41,7 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { recordApprovedBinding } from "@/altimate/workspace/state" +import { accountDigest, recordApprovedBinding } from "@/altimate/workspace/state" import type { SeedOutcome } from "@/altimate/workspace/memory-backfill" const CREATE_NEW_SENTINEL = "__create_new__" @@ -363,6 +363,8 @@ async function runBrowserHandoff( projectName: string, directory: string, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + const linkAccount = (await accountDigest()) ?? undefined const spin = prompts.spinner() spin.start("Waiting for browser approval (up to 15 min)...") const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) @@ -410,7 +412,7 @@ async function runBrowserHandoff( repoRemote: res.binding.repo_remote, projectPath: res.binding.project_path, linkedAt: Date.now(), - }, { awaitBackfill: true }) + }, { awaitBackfill: true, account: linkAccount }) bindSpin.stop(`Linked to "${stripControlChars(res.binding.datamate_name)}".`) prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) @@ -492,6 +494,8 @@ export async function createThenBindOrRebind( directory: string, existing: ProjectBindingLookup | null, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + const linkAccount = (await accountDigest()) ?? undefined const spin = prompts.spinner() spin.start(`Creating workspace "${name}"...`) // Discriminated on how the workspace was made, because the two creates return @@ -614,7 +618,7 @@ export async function createThenBindOrRebind( repoRemote: serverBinding?.repo_remote ?? identifier.repoRemote ?? null, projectPath: serverBinding?.project_path ?? identifier.projectPath ?? null, linkedAt: Date.now(), - }, { awaitBackfill: true }) + }, { awaitBackfill: true, account: linkAccount }) prompts.log.info(seedMessage(seed)) // The quick create is private, and the server hides a private workspace's link from // everyone else: a teammate who clones this repo is told it is unlinked. @@ -662,6 +666,8 @@ async function bindOrRebind( preCheckOk: boolean, directory: string, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + const linkAccount = (await accountDigest()) ?? undefined const isRebind = existing !== null const spin = prompts.spinner() spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) @@ -745,7 +751,7 @@ async function bindOrRebind( repoRemote: res.binding.repo_remote, projectPath: res.binding.project_path, linkedAt: Date.now(), - }, { awaitBackfill: true }) + }, { awaitBackfill: true, account: linkAccount }) const safeResName = stripControlChars(res.binding.datamate_name) spin.stop(isRebind ? `Re-linked to "${safeResName}".` : `Linked to "${safeResName}".`) prompts.log.info(seedMessage(seed)) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 45f3f12796..c6ae8b7624 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1916,7 +1916,12 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise // Under an IDE pin, skills, memory and routing follow the pinned workspace, so Open must too. // It only returns early (null) when there is no pin, so a throw means a pin exists but could // not be checked: keep it unresolved rather than falling through to the project's link. - const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) + // Bounded: a cold pin check is a live request, and the menu must not hang on a slow service. + const PIN_CHECK_MS = 1_500 + const pinned = await Promise.race([ + resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })), + new Promise<{ status: "unknown" }>((done) => setTimeout(() => done({ status: "unknown" }), PIN_CHECK_MS).unref?.()), + ]) // A pin that cannot be honoured fails closed everywhere else; Open must not fall through to // the project's own link either. const openId = pinned @@ -1945,16 +1950,24 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise ...(manageUrl ? [{ title: "Open in browser", value: "open", description: "View this workspace on the web." }] : []), - { title: "Switch workspace", value: "link", description: "Link this project to a different workspace." }, + // Under an IDE pin the session follows the pin, so relinking the project would appear to + // succeed while changing nothing here. + ...(pinned + ? [] + : [{ title: "Switch workspace", value: "link", description: "Link this project to a different workspace." }]), { title: "Unlink", value: "unlink", description: "Detach this project from the workspace." }, { title: "Done", value: "done", description: "Close this menu." }, ] : [ - { - title: "Link to a workspace", - value: "link", - description: "Pick an existing workspace or create one for this project.", - }, + ...(pinned + ? [] + : [ + { + title: "Link to a workspace", + value: "link", + description: "Pick an existing workspace or create one for this project.", + }, + ]), // An IDE pin can govern a project that has no link of its own. ...(manageUrl ? [{ title: "Open in browser", value: "open", description: "View the pinned workspace on the web." }] @@ -1962,7 +1975,7 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { title: "Done", value: "done", description: "Close this menu." }, ] } - current={linked ? "refresh" : "link"} + current={linked ? "refresh" : pinned ? "done" : "link"} onSelect={(option) => { if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") From b3cf963cdf5a21c39384aa8ebb918a5a4238ee03 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 25 Sep 2026 03:29:24 -0700 Subject: [PATCH 19/19] fix(workspace): refuse link without readable credentials; no Unlink under a pin Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/cli/cmd/link.ts | 24 ++++++++++++++++--- .../src/plugin/tui/altimate/workspace.tsx | 5 +++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index a74d4fb599..e7fe3bad88 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -364,7 +364,13 @@ async function runBrowserHandoff( directory: string, ): Promise { // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. - const linkAccount = (await accountDigest()) ?? undefined + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const spin = prompts.spinner() spin.start("Waiting for browser approval (up to 15 min)...") const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) @@ -495,7 +501,13 @@ export async function createThenBindOrRebind( existing: ProjectBindingLookup | null, ): Promise { // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. - const linkAccount = (await accountDigest()) ?? undefined + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const spin = prompts.spinner() spin.start(`Creating workspace "${name}"...`) // Discriminated on how the workspace was made, because the two creates return @@ -667,7 +679,13 @@ async function bindOrRebind( directory: string, ): Promise { // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. - const linkAccount = (await accountDigest()) ?? undefined + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const isRebind = existing !== null const spin = prompts.spinner() spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index c6ae8b7624..8ad6793c9c 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1955,7 +1955,10 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise ...(pinned ? [] : [{ title: "Switch workspace", value: "link", description: "Link this project to a different workspace." }]), - { title: "Unlink", value: "unlink", description: "Detach this project from the workspace." }, + // Unlink edits the project's own binding, which a pinned session does not use. + ...(pinned + ? [] + : [{ title: "Unlink", value: "unlink", description: "Detach this project from the workspace." }]), { title: "Done", value: "done", description: "Close this menu." }, ] : [