Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.5] - 2026-10-06

**Workspaces are on by default.** Everyone signed in to Altimate now gets project linking, workspace skills and memory sync, the `/workspace` menu, the sidebar tile and a workspace section in the boot box, with no `ALTIMATE_WORKSPACE=1` needed. `ALTIMATE_DISABLE_WORKSPACE=1` turns all of it off. Also in this release: Snowflake connections that reopen instead of failing until restart, passwords added through `warehouse_add` that survive a restart, a crash-safe debug mode with `altimate debug bundle`, headless `workspace` commands, and `altimate-code learn`.

**Heads-up for support:**
- **Network traffic for signed-in users.** Each process now asks the Altimate service whether the project is linked. That is two requests for an unlinked project (by git remote, then by folder path) and one for a project linked by remote, sending the `origin` URL (credentials removed) and the project's absolute path. The running process keeps the answer and asks again at most every 5 minutes, but every separate `altimate-code run` asks once. A failed lookup is retried on the next turn, and the turn waits at most 2 s for it. Users who are not signed in send nothing. `ALTIMATE_DISABLE_WORKSPACE=1` stops all of it.
- **Startup changes for signed-in users.** The boot box shows a "Workspace mode" section in every project; in an unlinked one it says how to link. After the first-launch scan, a dialog offers to set up or link a workspace (Skip hides it for 7 days per project and account).
- **`ALTIMATE_WORKSPACE` is retired**; setting it does nothing.

### Changed

- **Workspaces are on by default, with `ALTIMATE_DISABLE_WORKSPACE` as the kill switch.** The value is trimmed and read without regard to case, and anything other than `0`, `false` or an empty value turns workspaces off. With the switch set, a project that is already linked keeps its link but ignores it: workspace skills already pulled into the project are taken out of service, the agent is not told about the workspace, and the MCP entries the workspace manages can be edited by hand again. `link`, `workspace` and `skill publish` print "Workspaces are turned off because ALTIMATE_DISABLE_WORKSPACE is set", and `serve`'s workspace routes answer 409. (#1381)
- **Workspace pages open in the Altimate web app's `/workspaces` section** (`https://<tenant>.app.myaltimate.com/workspaces`) instead of `<tenant>.ws.myaltimate.com`, which is being retired. `ALTIMATE_WORKSPACE_WEB_URL` must now include the mount path. (#1360, #1393)
- **The attach notice is one line.** It gives counts ("2 of 9 integration tools available · 7 need attention. Details: /workspace") instead of listing every missing tool. `/workspace` → **Status** lists each integration with its counts, reasons and tools, and works offline from the last attach; the sidebar tile and the boot box show the same headline. (#1312)
- **Missing integration tools now come with reasons**, read from the workspace engine's report (`@altimateai/datamate` 0.7.3 or later): an expired token, an MCP server that could not start (with the engine's detail), an integration removed from the catalog. Previously every missing tool looked the same. (#1308)

### Added

- **Workspace commands for scripts, devcontainers and CI.** `altimate-code workspace status|refresh|sync|unlink [--json] [--directory]` and a non-interactive `altimate-code link --workspace <name|id>` or `--create [name]`. Re-running `--create` for the workspace the project is already linked to creates nothing, so it is safe in a setup command. Replacing a link, or unlinking without a terminal, needs `--yes`. On a fresh clone, `workspace sync` does not send this machine's memory until `link --workspace <id>` confirms the link here. Exit codes: `0` done, `1` failed (including an unreachable service, which is never reported as "not linked"), `2` a request to change, `3` not linked. (#1396)
- **The agent answers "what does this workspace have" from the workspace.** For a linked project, the system prompt lists the workspace's own skills (from the synced copy, sorted, with a skill whose `SKILL.md` cannot be read named as unreadable), its integrations, its memory setting and its knowledge sources. Previously the agent listed every installed skill. (#1397)
- **`altimate-code learn`: the agent learns team conventions from corrections.** Capture is opt-in (`learn enable`). A model proposes lessons, and a person reviews and promotes them (`learn show`, `learn promote`); nothing reaches the agent before that. Promoted lessons are delivered by retrieval, up to 15 core and 15 retrieved per session, and `learn bootstrap` and `learn import-reviews` seed lessons from past sessions and merged GitHub reviews after showing what they will read. Local only; nothing is shared unless you run `learn promote --publish`. `learn.enabled: false` or `ALTIMATE_LEARN=0` turns it off. Projects without approved lessons get no lessons; the only change is a dismissible TUI tip after two corrections in a session, shown at most three times (`learn nudge off` hides it). (#1405)
- **Debug mode that survives a crash, and `altimate debug bundle`.** `ALTIMATE_DEBUG=1` writes every tool call to `opencode.log` as it starts and ends, with a heartbeat every 15 s for calls still running, appended synchronously so the last lines survive a kill. `altimate debug bundle [--output <path>] [--no-network]` writes a local Markdown report: findings first (runs that ended mid-tool, connections with no retrievable password, unfinished browser sign-ins, unreachable hosts, long stalls), then the evidence. Passwords, keys, tokens, emails, URL queries, the home folder and the user name are removed, and nothing is uploaded. Always on: one `altimate-code started` line per process, and every event-loop stall is logged. (#1409, #1414)
- **Gateway requests carry the IDE extension's trace.** A `traceparent` sent with a prompt is passed to the Altimate gateway as a child span with a matching `x-request-id`, so a failed turn can be followed across the extension, `serve` and the gateway. Altimate providers only. (#1400)

### Fixed

- **Closed Snowflake connections are reopened instead of failing until restart.** Once Snowflake closed a session (idle expiry, VPN drop, laptop sleep), every later statement failed with `Unable to perform operation using terminated connection`. The driver now keeps the session alive (`client_session_keep_alive: false` opts out), reopens a dead connection, and replays `USE`, `ALTER SESSION`, `SET` and `UNSET` onto it. A statement is resent only when the error shows it never reached Snowflake, and never one that consumes a sequence value or calls `SYSTEM$`. Temporary tables and an open transaction cannot be restored, so statements that relied on them report it instead of running. (#1395, #1414)
- **Passwords added through `warehouse_add` are kept after a restart.** The credential store needed an optional module that released binaries do not include, so every password, key and token was dropped from the saved connection and the next start failed with `A password must be specified`. Secrets now go to the OS credential store (macOS Keychain, Windows Credential Manager, libsecret on Linux), and a secret that cannot be stored or removed is reported as a warning. (#1395, #1414)
- **Snowflake browser sign-in is visible.** With `authenticator: externalbrowser` the TUI shows **Waiting for sign-in** with the link, headless `run` prints one line, and a sign-in nobody completes tells the agent to ask the user instead of retrying. Other logins fail after 120 s with a network or VPN hint instead of the SDK's 300 s. (#1395)
- **The terminal is restored when the process exits without closing the TUI**, so the shell no longer receives mouse moves and key presses as text. (#1395, #1414)
- **The prompt cache hits on the second model call of each turn in a linked project.** The tool list was reordered between calls, so every turn re-sent the whole prompt uncached. (#1401)
- **The link pickers open on your own workspace that already has the project's name**, instead of creating a second one on a plain Enter. A teammate's workspace of that name is listed but not preselected, and creating a duplicate asks first. (#1379)
- **A project linked to a teammate's private workspace is no longer a dead end in the TUI.** Quick create says to ask the owner to share the workspace or unlink the project, and creating a workspace in the TUI now says it is private. (#1390)
- **A workspace created moments ago no longer reads as "memory off".** The service's workspace list can leave out a just-created workspace for a few minutes, and the client read that as memory switched off: `workspace sync` and linking said "Memory is off for this workspace" and sent nothing, and `workspace status` counted everything as already in the workspace. It is now treated as not yet known, so linking and sync say to retry; nothing is sent until the setting is confirmed. Found in this release's testing.
- **Workspace memory:** a block whose cloud record was deleted elsewhere is re-created on its next save instead of failing on every sync, and a block archived elsewhere is removed on this machine when the archive is newer than the block and the block is unchanged. At most 25 blocks are removed per load. (#1380)

### Known limitations

- **Workspace skills can add standing instructions.** A synced skill keeps its `alwaysApply` / `applyPaths` frontmatter, so anyone who can upload a skill to a workspace can add instructions to every linked member's prompt. This was approved for the pilot and re-approved for default-on.
- **A block whose cloud record was deleted** is re-created only when it is next edited, not on the next load. (#1380)
- **`learn` retrieval is keyword-based**, and a second unrelated request in a session gets only part of the lessons it needs. (#1405)

## [0.12.4] - 2026-09-29

One fix for every TUI user, and a round of workspace-pilot work: the IDE extension can drive `/workspace` and `skill publish` over `serve`, a sync says which skills it skipped, and cached workspace state is scoped to the signed-in account. Everything under **Added** is behind `ALTIMATE_WORKSPACE=1`. **Heads-up for support (pilot):** after upgrading, each project's cached workspace link and synced skills are discarded and fetched again on first use (the cache format changed to carry the account), so the first turn in a linked project re-syncs.
Expand Down
19 changes: 17 additions & 2 deletions packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export function resetEnablementMemoForTests(): void {

/** Warn once per workspace, not once per write. */
const missingFieldWarned = new Set<number>()
/** Workspaces already reported missing from the list, so a lag of a few minutes warns once, not on every call. */
const missingFromListWarned = new Set<number>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: missingFromListWarned is keyed only by datamateId, but workspace IDs are tenant-local. After an account switch, a missing workspace 42 can inherit tenant A's warning suppression in tenant B; scope this set by credential identity as pollMemo does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/workspace/memory-sync.ts, line 217:

<comment>`missingFromListWarned` is keyed only by `datamateId`, but workspace IDs are tenant-local. After an account switch, a missing workspace 42 can inherit tenant A's warning suppression in tenant B; scope this set by credential identity as `pollMemo` does.</comment>

<file context>
@@ -213,6 +213,8 @@ export function resetEnablementMemoForTests(): void {
 /** Warn once per workspace, not once per write. */
 const missingFieldWarned = new Set<number>()
+/** Workspaces already reported missing from the list, so a lag of a few minutes warns once, not on every call. */
+const missingFromListWarned = new Set<number>()
 
 /** Whether the bound workspace has memory switched on.
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this in the release. The set only decides whether one log.warn line is written. It does not touch the memory verdict: the missing branch returns "error" and caches nothing, for any tenant. The worst case after an account switch is one skipped warning line for the same workspace id in another tenant. That matches the existing missingFieldWarned, which is keyed the same way. Scoping both by credential would be the consistent change, and it can come in a normal PR.


/** Whether the bound workspace has memory switched on.
*
Expand Down Expand Up @@ -244,15 +246,28 @@ async function memoryStatus(
try {
const workspaces = await WorkspaceApi.listDatamates()
const match = workspaces.find((w) => w.id === binding.datamateId)
if (match && match.memoryEnabled === undefined && !missingFieldWarned.has(binding.datamateId)) {
if (!match) {
// Missing from the list is not a confirmed toggle. The list lags a workspace created moments ago (one
// service replica serves it stale for a few minutes), so this is unknown, like a failed request: neither
// verdict is kept, so cache-only readers say unknown too, and the write path still fails closed on it.
memoryEnabledCache.delete(binding.datamateId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Clear the cached disabled verdict when the workspace disappears

This branch deletes only memoryEnabledCache, but a prior successful lookup with memory_enabled: false left an entry in memoryDisabledMemo. If the next /datamates/ response omits that workspace (the documented replica lag), memoryStatus returns error while memoryEnabledCached still returns disabled for up to five minutes. The non-polling workspace status path (manage.memoryCounts via pendingCount(..., { network: false })) therefore continues to report 0 outstanding rather than unknown. Clear the negative memo here as well and cover the disabled-then-missing sequence; the new test starts with an empty memo.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 70f842c. The missing-from-list branch now also clears memoryDisabledMemo, so memoryEnabledCached reads "unknown" and the non-polling status path no longer reports 0 outstanding. New test: "a workspace last seen with memory off that drops out of the list reads as unknown, not off". It goes disabled, then missing, and fails with the memo delete removed.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
memoryDisabledMemo.delete(binding.datamateId)
if (!missingFromListWarned.has(binding.datamateId)) {
missingFromListWarned.add(binding.datamateId)
log.warn("workspace missing from the workspace list; memory setting unknown", { workspace: binding.datamateId })
}
return "error"
}
missingFromListWarned.delete(binding.datamateId)
if (match.memoryEnabled === undefined && !missingFieldWarned.has(binding.datamateId)) {
// Fail-closed is right, but a backend that has not shipped the field
// turns the whole feature into a silent no-op. Say so once.
missingFieldWarned.add(binding.datamateId)
log.warn("workspace has no memory_enabled field; treating memory as disabled", {
workspace: binding.datamateId,
})
}
const value = match?.memoryEnabled === true
const value = match.memoryEnabled === true
if (value) {
memoryEnabledCache.set(binding.datamateId, { checkedAt: Date.now() })
memoryDisabledMemo.delete(binding.datamateId)
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/altimate/workspace/manage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ afterAll(() => {
;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds
})

/** The workspace list answering that workspace 42 has memory switched off. A workspace merely missing from the
* list is "unknown", not off (the list lags a workspace created moments ago). */
function memoryOffList() {
const prior = globalThis.fetch
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url
if (url.endsWith("/datamates/"))
return new Response(JSON.stringify({ datamates: [{ id: 42, name: "Growth", memory_enabled: false }] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return prior(input, init)
}) as typeof fetch
}

async function bind(dir: string, datamateId = 42) {
// Awaited, so the bind's skill sync and memory backfill finish inside this
// test's stubbed `fetch` and its `requests` log. Detached, they straddled
Expand Down Expand Up @@ -238,6 +253,7 @@ describe("status", () => {
// nothing at all would hide the first fact to protect the second.
await bind(projectDir)
resetPollMemoForTests()
memoryOffList()

const report = await status(projectDir, { poll: true })

Expand Down Expand Up @@ -343,11 +359,22 @@ describe("what the status line is allowed to claim", () => {
expect(report.memory?.unsynced).toBeNull()
})

test("does not report '0 not synced' for a workspace the list does not show yet", async () => {
// The list lags a workspace created moments ago. That is not "memory off", so the count of what is
// outstanding is unknown, not 0.
await bind(projectDir)
resetPollMemoForTests()
const report = await status(projectDir, { poll: true })
expect(report.memory).not.toBeNull()
expect(report.memory?.unsynced).toBeNull()
})

test("still reports 0 outstanding when memory is genuinely off", async () => {
// The contrast that gives the test above its meaning: "disabled" IS an
// answer, and 0 is the truth for it.
await bind(projectDir)
resetPollMemoForTests()
memoryOffList()
const report = await status(projectDir, { poll: true })
expect(report.memory?.unsynced).toBe(0)
})
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/altimate/workspace/memory-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,28 @@ describe("memory_enabled", () => {
await mirrorBlock(block({ id: "unknown-ws", scope: "global" }))
expect(callsTo("/datamates/memory/", "POST").length).toBe(0)
})

test("a workspace missing from the list is unknown, not memory off", async () => {
// The list lags a workspace created moments ago. Reading that as "memory off" made sync say so, made the bind
// seed report "off" instead of asking for a retry, and memoized "disabled" so status counted nothing unsent.
workspaces = []
const swept = await backfill([block({ id: "lagging" })], BINDING as any)
expect(swept.gated).toBe(true)
expect(swept.gateReason).toBe("error")
expect(memoryEnabledCached(BINDING as any)).toBe("unknown")
expect(callsTo("/datamates/memory/", "POST").length).toBe(0)
})

test("a workspace last seen with memory off that drops out of the list reads as unknown, not off", async () => {
// The cache-only readers (`workspace status` without a poll, the identity section) must not keep serving the
// earlier "disabled" while the list cannot say anything about the workspace.
workspaces = [{ id: 42, name: "acme", memory_enabled: false }]
await backfill([block({ id: "was-off" })], BINDING as any)
expect(memoryEnabledCached(BINDING as any)).toBe("disabled")
workspaces = []
await backfill([block({ id: "now-missing" })], BINDING as any)
expect(memoryEnabledCached(BINDING as any)).toBe("unknown")
})
})

// ── read path ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1646,6 +1668,11 @@ describe("truncated reads", () => {
workspaces = [{ id: 42, name: "acme", memory_enabled: false }]
expect((await seedOnBind(dir, BINDING as any)).status).toBe("off")

// A workspace the list does not show yet is a retry, not "memory is off".
resetOverlay()
workspaces = []
expect((await seedOnBind(dir, BINDING as any)).status).toBe("incomplete")

// A failed enablement lookup gates the sweep too, but is not "memory is off".
resetOverlay()
workspaces = []
Expand Down
Loading