From 51bca9e29b02068572e08e9cc16ea72dae8ca2ab Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:23:17 +0800 Subject: [PATCH 01/19] feat: the bound workspace rides on every event of a session Each prompt turn resolves the project's local binding (no network) into the telemetry context, and the envelope builder stamps workspace_id on every row of the session; a row's own id wins. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 14 +++- packages/opencode/src/session/prompt.ts | 13 ++- .../telemetry/workspace-context.test.ts | 80 +++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/altimate/telemetry/workspace-context.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 9fc4ee78d5..85b0fe961e 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1914,6 +1914,11 @@ export namespace Telemetry { let machineId = "" let sessionId = "" let projectId = "" + // altimate_change — the workspace the current session's project is linked to (the backend + // datamate id), set per prompt turn from the local binding cache; stamped on every envelope so + // the session, first-use, skill, memory, tool and outcome rows join the workspace without each + // emit site knowing about it. + let workspaceId: number | undefined let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 let initPromise: Promise | undefined @@ -1998,6 +2003,7 @@ export namespace Telemetry { // still says how this CLI was launched (cli, tui, serve, datamates). client: "altimate-code", source: clientSource, + ...(workspaceId !== undefined && !("workspace_id" in fields) && { workspace_id: String(workspaceId) }), project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), // altimate_change — groups every event from one process launch. The onboarding funnel @@ -2177,13 +2183,14 @@ export namespace Telemetry { } } - export function setContext(opts: { sessionId: string; projectId: string }) { + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { sessionId = opts.sessionId projectId = opts.projectId + workspaceId = opts.workspaceId } - export function getContext() { - return { sessionId, projectId } + export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { + return { sessionId, projectId, ...(workspaceId !== undefined ? { workspaceId } : {}) } } /** Returns true only after init() has completed and telemetry is enabled. */ @@ -2529,6 +2536,7 @@ export namespace Telemetry { droppedEvents = 0 sessionId = "" projectId = "" + workspaceId = undefined machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 554f8a1e22..9227f94f45 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -106,6 +106,9 @@ import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-sourc // altimate_change end // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry +// altimate_change start — the session's bound workspace rides on every telemetry row +import { readLocalBinding } from "@/altimate/workspace/state" +// altimate_change end import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — client trace per turn import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change — onboarding funnel // altimate_change start — keyless public Zen predicate (flat module) @@ -727,7 +730,15 @@ export namespace SessionPrompt { throw e } // altimate_change end - Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "" }) + // altimate_change start — the workspace this project is linked to (local binding cache, no + // network) goes on the telemetry context, so every row of the turn carries workspace_id. + const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) + Telemetry.setContext({ + sessionId: sessionID, + projectId: Instance.project?.id ?? "", + workspaceId: boundWorkspace?.datamateId, + }) + // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, // create state, or even schedule async learning work on that default-off path. diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts new file mode 100644 index 0000000000..a1952878cd --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdtempSync, rmSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" + +describe("workspace id on the telemetry context", () => { + let home: string + let posts: string[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedCs: string | undefined + let savedHome: string | undefined + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ws-")) + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + posts = [] + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.includes("/v2/track")) posts.push(String(init?.body ?? "")) + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + fetchSpy.mockRestore() + homedirSpy.mockRestore() + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + if (savedHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = savedHome + rmSync(home, { recursive: true, force: true }) + }) + + const envelopes = () => posts.flatMap((b) => JSON.parse(b) as any[]) + const startup = (command: string) => + ({ + type: "startup_ready", + session_id: "", + command, + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) as const + + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { + await Telemetry.shutdown() + await Telemetry.init() + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) + expect(Telemetry.getContext().workspaceId).toBe(42) + + Telemetry.track(startup("bound")) + Telemetry.track({ + type: "workspace_linked", + timestamp: Date.now(), + session_id: "s1", + workspace_id: 7, + how: "bind_existing", + }) + await Telemetry.flush() + const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") + expect(bound.data.baseData.properties.workspace_id).toBe("42") + expect(bound.data.baseData.properties.client).toBe("altimate-code") + const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") + expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + + Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) + Telemetry.track(startup("unbound")) + await Telemetry.flush() + const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") + expect("workspace_id" in unbound.data.baseData.properties).toBe(false) + }) +}) From 88d648aa236810b24e11a1b8b2592b0e6b6b2a07 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:00:08 +0800 Subject: [PATCH 02/19] feat: usage rows of a linked workspace Workspace skills, memory and integration calls carry a workspace value and are counted per session for session_end and task_outcome_signal; new starter_shown, workspace_memory_contributed and workspace_sync_failed; skill_published carries the workspace id. Closes #1510 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 63 ++++++- .../src/altimate/workspace/memory-sync.ts | 78 ++++----- .../src/altimate/workspace/starter.ts | 38 ++++- .../src/altimate/workspace/sync-state.ts | 27 +++ packages/opencode/src/cli/cmd/skill.ts | 1 + packages/opencode/src/memory/prompt.ts | 20 +++ packages/opencode/src/server/server.ts | 1 + packages/opencode/src/session/prompt.ts | 20 +++ packages/opencode/src/tool/skill.ts | 9 +- packages/opencode/src/tool/tool.ts | 14 ++ .../telemetry/workspace-context.test.ts | 15 ++ .../altimate/workspace/memory-sync.test.ts | 161 ++++++++++++------ .../test/altimate/workspace/starter.test.ts | 47 ++++- .../altimate/workspace/sync-state.test.ts | 55 +++++- .../test/memory/overlay-merge.test.ts | 43 ++++- .../opencode/test/tool/skill-source.test.ts | 7 + 16 files changed, 487 insertions(+), 112 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 85b0fe961e..5f6939c07c 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -258,6 +258,11 @@ export namespace Telemetry { total_tokens: number tool_call_count: number duration_ms: number + // altimate_change — what of the bound workspace this session used (see noteWorkspaceUse) + workspace_skills_used?: number + workspace_documents_used?: number + workspace_memory_blocks?: number + workspace_integration_calls?: number } | { type: "generation" @@ -316,6 +321,8 @@ export namespace Telemetry { previous_tool: string | null input_signature?: string error?: string + // altimate_change — the call went to the bound workspace (a datamate MCP server) + via_workspace?: boolean } | { type: "native_call" @@ -596,6 +603,34 @@ export namespace Telemetry { status?: number workspace_id?: number } + | { + /** The post-link starter message was served (TUI or extension chat); what the workspace provides. */ + type: "starter_shown" + timestamp: number + session_id: string + workspace_id: number + skills: number + integrations: number + knowledge: string + memory_enabled: boolean + } + | { + /** A memory block was written back to the bound workspace's cloud memory. */ + type: "workspace_memory_contributed" + timestamp: number + session_id: string + workspace_id: number + block_count: number + } + | { + /** A kind's sync status flipped to error. */ + type: "workspace_sync_failed" + timestamp: number + session_id: string + workspace_id: number + kind: "skills" | "memory" + reason: "auth" | "not_found" | "network" | "other" + } // altimate_change end | { type: "mcp_server_census" @@ -633,6 +668,8 @@ export namespace Telemetry { total_chars: number budget: number scopes_used: string[] + /** Blocks that came from the bound workspace's cloud memory (overlay). */ + workspace_blocks?: number } | { // Sent before connecting, so an attempt that hangs or ends the process is still counted. @@ -701,7 +738,7 @@ export namespace Telemetry { session_id: string message_id: string skill_name: string - skill_source: "builtin" | "global" | "project" + skill_source: "builtin" | "global" | "project" | "workspace" duration_ms: number // altimate_change start — skill trigger classification for discovery analytics trigger: "user_command" | "llm_selected" | "auto_suggested" | "unknown" @@ -801,6 +838,8 @@ export namespace Telemetry { file_count: number // "serve": published from the IDE extension over the serve route source: "cli" | "tui" | "serve" + // altimate_change — the workspace the skill was attached to + workspace_id?: number } // altimate_change end // altimate_change start — plan refinement telemetry event @@ -914,6 +953,8 @@ export namespace Telemetry { duration_ms: number /** Last tool category the agent used (or "none") */ last_tool_category: string + // altimate_change — true when the session had used workspace skills, memory, documents or integrations + workspace_context_used?: boolean } // task intent classification for understanding DE problem distribution | { @@ -2183,6 +2224,26 @@ export namespace Telemetry { } } + // altimate_change start — per-session use of the bound workspace. The skill, memory and tool + // emit sites note what they used; session_end reports the counts and task_outcome_signal + // whether any workspace context was in play. Keyed by session so a serve process with several + // sessions keeps them apart; cleared when the session ends. + export type WorkspaceUse = { skills: number; documents: number; memory: number; integrations: number } + const workspaceUse = new Map() + export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { + if (!sessionId || n <= 0) return + const use = workspaceUse.get(sessionId) ?? { skills: 0, documents: 0, memory: 0, integrations: 0 } + use[kind] += n + workspaceUse.set(sessionId, use) + } + export function workspaceUseOf(sessionId: string): WorkspaceUse { + return { skills: 0, documents: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + } + export function clearWorkspaceUse(sessionId: string) { + workspaceUse.delete(sessionId) + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { sessionId = opts.sessionId projectId = opts.projectId diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 6302d8bdc7..696a9d1be4 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -16,6 +16,7 @@ // Nothing is written to disk: local files are the source of truth, and a cloud // record can have been edited elsewhere by a client that does not preserve this // CLI's metadata. +import { Telemetry } from "@/altimate/telemetry" import { createHash } from "node:crypto" import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Flag } from "@/flag/flag" @@ -332,11 +333,7 @@ function stripTrainingMeta(content: string): string { /** Fingerprint of everything about a block that reaches the store. Tags and * expiry are included because both are mirrored. */ function contentHash(block: MemoryBlock): string { - const payload = JSON.stringify([ - stripTrainingMeta(block.content), - [...block.tags].sort(), - block.expires ?? "", - ]) + const payload = JSON.stringify([stripTrainingMeta(block.content), [...block.tags].sort(), block.expires ?? ""]) // sha-256, not a 32-bit non-cryptographic hash. This value is the single // gate deciding whether a save is sent at all: on a collision ``push`` // returns "unchanged" and a real edit is silently never mirrored, with no @@ -656,7 +653,9 @@ async function reapArchivedBlocks( try { const gone = syncInternals.removeBlock ? await syncInternals.removeBlock(scope, blockId, directory ?? undefined, block.updated) - : await (await import("@/memory/store")).MemoryStore.remove(scope, blockId, directory ?? undefined, { + : await ( + await import("@/memory/store") + ).MemoryStore.remove(scope, blockId, directory ?? undefined, { expectUpdated: block.updated, }) if (gone) { @@ -724,9 +723,7 @@ async function push( // user recreated under an old id gets a fresh record instead of un-archiving // the old one (`MemoryApi.update` replaces metadata wholesale, which would // drop the archived marker). - const indexed = existing?.memoryId - ? view.records.find((r) => r.id === existing.memoryId) - : undefined + const indexed = existing?.memoryId ? view.records.find((r) => r.id === existing.memoryId) : undefined // A record the index names but a COMPLETE record set does not hold is gone — // deleted in the web app, by another client, or by a wipe. Absence used to // read as "still ours", so `match` stayed pointing at a dead id and every @@ -740,7 +737,8 @@ async function push( // that already has one — the duplicate this function works hardest to avoid. const indexedGone = existing?.memoryId !== undefined && indexed === undefined && !view.truncated const indexUsable = existing?.memoryId && !indexedGone && (!indexed || !isArchived(indexed)) - const match = (indexUsable ? existing?.memoryId : undefined) ?? view.records.find((r) => isSameBlock(r, block, binding))?.id + const match = + (indexUsable ? existing?.memoryId : undefined) ?? view.records.find((r) => isSameBlock(r, block, binding))?.id if (match) { // Refuse to move a record backwards. Two machines editing the same block, // or a stale clone running a sweep, would otherwise overwrite a newer cloud @@ -759,6 +757,7 @@ async function push( } await MemoryApi.update(match, block.content, metadata) await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) + if (binding) trackContributed(binding) return "stored" } @@ -793,9 +792,26 @@ async function push( log.warn("archiving an extra record produced by one create", { id: block.id, memoryId: extra }) await MemoryApi.update(extra, "", { ...metadata, archived: "true", archived_at: new Date().toISOString() }) } + if (binding) trackContributed(binding) return "stored" } +// altimate_change start — a successful write-back is the workspace getting something back +function trackContributed(binding: CachedBinding) { + try { + Telemetry.track({ + type: "workspace_memory_contributed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: binding.datamateId, + block_count: 1, + }) + } catch { + // telemetry never blocks a mirror + } +} +// altimate_change end + /** Cloud operations for one logical block, run one at a time. * * Both callers are fire-and-forget from the store's write and delete paths, so @@ -892,11 +908,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi /** Archive a block's cloud record rather than deleting it, so the workspace * keeps the history. Only this client filters the marker — other readers do * not — so an archived record stays visible elsewhere. */ -export async function archiveBlock( - scope: "global" | "project", - blockId: string, - directory?: string, -): Promise { +export async function archiveBlock(scope: "global" | "project", blockId: string, directory?: string): Promise { if (!isEnabled()) return // Queued behind any in-flight mirror for the same block, so a delete cannot // run before the create it is meant to undo. The binding lookup happens @@ -915,11 +927,7 @@ export async function archiveBlock( ) } -async function archiveNow( - scope: "global" | "project", - blockId: string, - binding: CachedBinding, -): Promise { +async function archiveNow(scope: "global" | "project", blockId: string, binding: CachedBinding): Promise { const key = indexKey({ scope, blockId, @@ -1082,9 +1090,7 @@ async function pollMemoKey(binding: CachedBinding): Promise { * * An "unknown" is never memoized: the next tick should ask again rather than * inherit a network blip. */ -export async function memoryEnabledForPoller( - binding: CachedBinding, -): Promise<"enabled" | "disabled" | "unknown"> { +export async function memoryEnabledForPoller(binding: CachedBinding): Promise<"enabled" | "disabled" | "unknown"> { // The scoped memo is the poller's only cache. `memoryEnabledCache` is keyed by // bare workspace id — right for the write path, which is tenant-bound by its // credentials — but consulting it here reopened a 60s cross-tenant window the @@ -1093,8 +1099,7 @@ export async function memoryEnabledForPoller( // minutes per tenant is the price, and `memoryStatus` still warms both. const key = await pollMemoKey(binding) const memo = pollMemo.get(key) - if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) - return memo.status + if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) return memo.status // The in-flight ask is memoized too, not only the settled answer. Two // refreshes overlapping on a cold memo — a remount while a slow one is // still out — both saw it empty and both put a request on the wire. @@ -1229,7 +1234,8 @@ export async function backfill( // ``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, gateReason: "local-off" } + 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()) @@ -1259,10 +1265,7 @@ export async function backfill( log.info("workspace memory backfill starting", { pending: pending.length, skipped }) const result = await runQueue( pending, - (item) => - serialize(item.block.scope, item.block.id, () => - push(item.block, item.binding, known, sweepDirectory), - ), + (item) => serialize(item.block.scope, item.block.id, () => push(item.block, item.binding, known, sweepDirectory)), BACKFILL_CONCURRENCY, ) // `skipped` combines blocks filtered before the queue (already synced, or @@ -1277,7 +1280,10 @@ export async function backfill( function originLabel(meta: Record): string | undefined { const remote = typeof meta.repo_remote === "string" ? meta.repo_remote : undefined if (remote) { - const trimmed = remote.replace(/[/]+$/, "").replace(/\.git$/, "").replace(/[/]+$/, "") + const trimmed = remote + .replace(/[/]+$/, "") + .replace(/\.git$/, "") + .replace(/[/]+$/, "") const last = trimmed.split(/[/:]/).pop() if (last) return last } @@ -1291,10 +1297,7 @@ function originLabel(meta: Record): string | undefined { /** Map a cloud record back to an injectable block, or null if it is not a * well-formed mirrored block. */ -export function toBlock( - record: CloudMemoryRecord, - ownProjectKey: string | undefined, -): RemoteMemoryBlock | null { +export function toBlock(record: CloudMemoryRecord, ownProjectKey: string | undefined): RemoteMemoryBlock | null { const meta = record.metadata if (!meta || typeof meta !== "object") return null const blockId = typeof meta.block_id === "string" ? meta.block_id : undefined @@ -1368,10 +1371,7 @@ export async function hydrate(sessionID: string): Promise { /** Wait for a session's in-flight hydration, capped. Resolves immediately once * the fetch has settled, so only the first injection of a session pays. */ -export async function whenHydrated( - sessionID: string, - timeoutMs: number = HYDRATION_WAIT_MS, -): Promise { +export async function whenHydrated(sessionID: string, timeoutMs: number = HYDRATION_WAIT_MS): Promise { const state = sessions.get(sessionID) const pending = state?.hydration if (!pending || !state) return diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 7532073926..67e3e654b0 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -5,6 +5,7 @@ // `GET /altimate/workspace/starter`. Fixed in format and built from the same data as the prompt's // "What this Altimate Workspace provides" section, so it costs no model call (none of the user's // tokens) and reads the same every time. Anything not known is left out, never shown as "none". +import { Telemetry } from "@/altimate/telemetry" import { displayWorkspaceName } from "./workspace-name" import { workspaceContents, type WorkspaceContents } from "./contents" @@ -33,10 +34,14 @@ function list(names: string[], max: number): string { } function skillsLine(skills: WorkspaceContents["skills"]): string | null { - if (skills === null) return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." + if (skills === null) + return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." if (skills === "unknown") return null if (skills.length === 0) return "Skills: none yet. Add them in the workspace and they sync here." - return `Skills (${skills.length}): ${list(skills.map((s) => s.name), MAX_LISTED_SKILLS)}` + return `Skills (${skills.length}): ${list( + skills.map((s) => s.name), + MAX_LISTED_SKILLS, + )}` } function knowledgeLine(k: WorkspaceContents["knowledge"]): string | null { @@ -86,7 +91,9 @@ function summaryOf(contents: WorkspaceContents): string { else if (k?.kind === "selected") parts.push(plural(k.names?.length ?? k.selected, "knowledge document")) else if (k?.kind === "off") parts.push("knowledge off") if (contents.integrations !== null) - parts.push(contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`) + parts.push( + contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`, + ) if (contents.memoryEnabled !== null) parts.push(contents.memoryEnabled ? "memory on" : "memory off") return parts.join(" · ") } @@ -138,10 +145,33 @@ export function wrapStarter(text: string, width: number): string { } /** The starter for a linked project. `directory` is the project key the link was recorded under. */ +// altimate_change start — the starter is the first thing a user sees of the workspace in a tool +/** `starter_shown`, with what the workspace provides as counts. Never throws. */ +export function trackStarterShown(workspaceId: number, contents: WorkspaceContents): void { + try { + Telemetry.track({ + type: "starter_shown", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: workspaceId, + skills: Array.isArray(contents.skills) ? contents.skills.length : 0, + integrations: Array.isArray(contents.integrations) ? contents.integrations.length : 0, + knowledge: contents.knowledge?.kind ?? "unknown", + memory_enabled: contents.memoryEnabled === true, + }) + } catch { + // telemetry never blocks the starter + } +} +// altimate_change end + export async function starterFor( directory: string, binding: { datamateId: number; datamateName: string }, waitMs = STARTER_WAIT_MS, ): Promise { - return renderStarter(binding.datamateName, await workspaceContents(directory, binding.datamateId, waitMs)) + const contents = await workspaceContents(directory, binding.datamateId, waitMs) + const starter = renderStarter(binding.datamateName, contents) + trackStarterShown(binding.datamateId, contents) + return starter } diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index 9e72ebc77b..ac428534d1 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -25,6 +25,7 @@ // run in its server worker. A refresh run on the TUI main thread, or by a separate CLI // process, publishes to no `/event` subscriber — the file is still written, and the TUI // sidebar and the status route read it from there. +import { Telemetry } from "@/altimate/telemetry" import { createHash, randomBytes } from "node:crypto" import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" @@ -275,6 +276,15 @@ function chain(file: string, work: () => Promise, kind: SyncKind): Promise return settled } +/** The reason class of a sync error message, for workspace_sync_failed. */ +export function syncFailureReason(error: string | undefined): "auth" | "not_found" | "network" | "other" { + const e = (error ?? "").toLowerCase() + if (/\b(401|403)\b|unauthori[sz]ed|forbidden|sign(ed)? in|credential/.test(e)) return "auth" + if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" + if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" + return "other" +} + async function apply( directory: string, datamateId: number, @@ -330,6 +340,23 @@ async function apply( } const file: StoredFile = { version: 2, directory: canon, scope, datamateId, entity: next } writePrivate(filePath(directory, kind), file) + // altimate_change start — a kind flipping to error is a blocker the dashboard counts; a repeat + // while already in error is not. + if (next.status === "error" && previous?.status !== "error") { + try { + Telemetry.track({ + type: "workspace_sync_failed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: datamateId, + kind, + reason: syncFailureReason(outcome.error), + }) + } catch { + // telemetry never blocks the record + } + } + // altimate_change end const errorChanged = (previous?.status ?? "ok") !== next.status || previous?.error !== next.error if (!changes && !errorChanged) return diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 53d1d86211..4b8d4ce86e 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -533,6 +533,7 @@ const SkillPublishCommand = cmd({ action: report.action, file_count: report.files, source: "cli", + workspace_id: report.datamateId, }) } catch {} } catch (err) { diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 89c4cb2208..984a4b6324 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -206,6 +206,8 @@ export namespace MemoryPrompt { let injectedCount = 0 const injectedTraining: MemoryBlock[] = [] const scopesSeen = new Set() + // altimate_change — blocks injected from the bound workspace's cloud memory + let workspaceBlocks = 0 // Group training blocks by kind for structured display const byKind = new Map() @@ -244,6 +246,12 @@ export namespace MemoryPrompt { injectedCount++ injectedTraining.push(item.block) scopesSeen.add(item.block.scope) + // altimate_change start — a block from the bound workspace's cloud memory counts as workspace scope + if (isRemote(item.block)) { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } @@ -263,6 +271,12 @@ export namespace MemoryPrompt { used += needed injectedCount++ scopesSeen.add(item.block.scope) + // altimate_change start — as above, for memory blocks + if (isRemote(item.block)) { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } } @@ -293,7 +307,13 @@ export namespace MemoryPrompt { total_chars: used, budget, scopes_used: [...scopesSeen], + // altimate_change start — workspace memory use, for memory_injection and the session's summary rows + ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) + if (workspaceBlocks > 0) { + Telemetry.noteWorkspaceUse(ctx?.sessionID ?? Telemetry.getContext().sessionId, "memory", workspaceBlocks) + } + // altimate_change end } return injectedCount > 0 ? result : "" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e5ff81ec39..b8960a591c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1331,6 +1331,7 @@ export namespace Server { action: report.action, file_count: report.files, source: "serve", + workspace_id: report.datamateId, }) } catch {} return c.json({ ok: true as const, report, message: describePublish(report) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9227f94f45..a6b18407c4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -108,6 +108,18 @@ import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-sourc import { Telemetry } from "@/telemetry" // altimate_change — session telemetry // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" + +/** The session's workspace use, as session_end fields; counts are cleared with the read. */ +function workspaceUseFields(sessionID: string) { + const use = Telemetry.workspaceUseOf(sessionID) + Telemetry.clearWorkspaceUse(sessionID) + return { + workspace_skills_used: use.skills, + workspace_documents_used: use.documents, + workspace_memory_blocks: use.memory, + workspace_integration_calls: use.integrations, + } +} // altimate_change end import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — client trace per turn import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change — onboarding funnel @@ -807,6 +819,9 @@ export namespace SessionPrompt { total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID), + // altimate_change end }) } process.once("beforeExit", emergencySessionEnd) @@ -2116,6 +2131,8 @@ export namespace SessionPrompt { step_count: step, duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", + // altimate_change — whether workspace skills, memory, documents or integrations were in play + workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome if (toolChain.length > 0) { @@ -2203,6 +2220,9 @@ export namespace SessionPrompt { total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID), + // altimate_change end }) } await Telemetry.shutdown() diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index a33dd49a34..066331aa2a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -23,7 +23,7 @@ import { isBuiltinSkillLocation } from "../altimate/skill-source" const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge -export function classifySkillSource(location: string): "builtin" | "global" | "project" { +export function classifySkillSource(location: string): "builtin" | "global" | "project" | "workspace" { // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. const normalized = location.replace(/\\/g, "/") // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's @@ -33,6 +33,10 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // `node_modules/` isn't tagged as Altimate. // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart if (isBuiltinSkillLocation(location)) return "builtin" + // altimate_change start — skills synced from the bound workspace live under the managed + // `_workspace` folder; they are the workspace's, not the project's. + if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" + // altimate_change end if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } @@ -257,6 +261,9 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location) + // altimate_change start — count workspace skill use for the session's summary rows + if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") + // altimate_change end // altimate_change end // altimate_change start — telemetry instrumentation for skill loading with trigger classification diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c4bd52eca1..9192980acf 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -131,6 +131,13 @@ export type InferDef = : never // altimate_change start — telemetry helpers (ported from the fork's pre-1.17 Tool.define wrapper) +// altimate_change start — a tool whose id starts with the datamate prefix is an MCP tool of the bound +// workspace (mirrors ALTIMATE_MCP_PREFIXES in altimate/tool-source.ts: `datamate`, `datamates`, `datamate-…`). +function viaWorkspace(id: string): boolean { + return /^datamates?([_-]|$)/i.test(id) +} +// altimate_change end + function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: number, error: unknown) { try { const errorMsg = error instanceof Error ? error.message : String(error) @@ -144,6 +151,8 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: tool_type: "standard", tool_category: Telemetry.categorizeToolName(id, "standard"), status: "error", + // altimate_change — workspace integration call + ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: Date.now() - startTime, sequence_index: 0, previous_tool: null, @@ -177,6 +186,9 @@ function altimateTrackSuccess( try { const isSoftFailure = result.metadata?.success === false const durationMs = Date.now() - startTime + // altimate_change start — count successful calls to the bound workspace's integrations + if (!isSoftFailure && viaWorkspace(id)) Telemetry.noteWorkspaceUse(ctx.sessionID, "integrations") + // altimate_change end const toolCategory = Telemetry.categorizeToolName(id, "standard") // Skip success tool_call for high-volume/low-signal file tools; failures still tracked. if (isSoftFailure || toolCategory !== "file") { @@ -189,6 +201,8 @@ function altimateTrackSuccess( tool_type: "standard", tool_category: toolCategory, status: isSoftFailure ? "error" : "success", + // altimate_change — workspace integration call + ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: durationMs, sequence_index: 0, previous_tool: null, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index a1952878cd..60e9d45507 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -50,6 +50,21 @@ describe("workspace id on the telemetry context", () => { timestamp: Date.now(), }) as const + test("workspace use is counted per session and cleared on read-out", () => { + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.noteWorkspaceUse("s9", "skills") + Telemetry.noteWorkspaceUse("s9", "memory", 3) + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("", "skills") // no session: ignored + Telemetry.noteWorkspaceUse("s9", "documents", 0) // nothing to count + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, documents: 0, memory: 3, integrations: 2 }) + expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + }) + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { await Telemetry.shutdown() await Telemetry.init() diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index b32598d174..d991cb2155 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -6,7 +6,7 @@ // the mirror actually issues — method, path, body — rather than a mock's call // log. Cases claiming "nothing was sent" check a zero request count, not merely // the absence of a throw. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -53,9 +53,7 @@ const { toBlock, whenHydrated, } = await import("../../../src/altimate/workspace/memory-sync") -const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import( - "../../../src/altimate/workspace/memory-api" -) +const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import("../../../src/altimate/workspace/memory-api") import { AltimateApi } from "../../../src/altimate/api/client" @@ -113,7 +111,8 @@ function stubFetch() { if (method === "POST") { const body = init?.body ? JSON.parse(init.body) : {} for (const rec of Array.isArray(createResult) ? createResult : []) { - if (rec?.id) listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) + if (rec?.id) + listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) } } return { message: "ok", result: createResult } @@ -121,8 +120,7 @@ function stubFetch() { if (url.includes("/datamates/")) return { datamates: workspaces } return { message: "ok" } })() - const status = - url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 + const status = url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json" }, @@ -182,10 +180,8 @@ afterEach(() => { // seam fall through to the real lookup, so a leaked value from a previous // test would decide their outcome. serverBinding = null - ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = - originalIsConfigured - ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = - originalGetCreds + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds delete process.env.ALTIMATE_DISABLE_WORKSPACE delete syncInternals.resolveBinding delete syncInternals.blockExists @@ -516,7 +512,11 @@ describe("mirrorBlock", () => { createResult = [{ id: "mem-archive-late" }] await mirrorBlock(b) listResponse = [ - { id: "mem-archive-late", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" } }, + { + id: "mem-archive-late", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" }, + }, ] const net = gatedFetch() let settled = false @@ -828,8 +828,7 @@ describe("mirrorBlock", () => { // incrementApplied rewrites a training block on EVERY session start to bump // a counter inside the content body. Mirroring that would cost a request // per training block per session. - const meta = (applied: number) => - `\nAlways alias CTEs.` + const meta = (applied: number) => `\nAlways alias CTEs.` const b = block({ id: "training/rule/aliases", tags: ["training", "rule"], content: meta(1) }) await mirrorBlock(b) captured = [] @@ -876,7 +875,7 @@ describe("memory_enabled", () => { expect(callsTo("/datamates/memory/", "POST").length).toBe(0) workspaces = [{ id: 42, name: "acme", memory_enabled: true }] - serverBinding = null + serverBinding = null captured = [] await mirrorBlock(block({ id: "after-enable" })) expect(callsTo("/datamates/memory/", "POST").length).toBe(1) @@ -955,10 +954,7 @@ describe("toBlock", () => { test("a record from this same project is not labelled", () => { const own = "ssh://git@github.com/acme/analytics.git" - const b = toBlock( - record({ source: MIRROR_SOURCE, block_id: "x", block_scope: "project", repo_remote: own }), - own, - ) + const b = toBlock(record({ source: MIRROR_SOURCE, block_id: "x", block_scope: "project", repo_remote: own }), own) expect(b?.origin).toBeUndefined() }) @@ -990,10 +986,7 @@ describe("tag encoding", () => { // The previous comma-joined form split this tag into two on read. const tags = ["warehouse", "owner: data, platform", "prod"] const meta = buildMetadata(block({ tags }), null) - const back = toBlock( - { id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, - undefined, - ) + const back = toBlock({ id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, undefined) expect(back?.tags).toEqual(tags) }) @@ -1053,9 +1046,7 @@ describe("reaping blocks archived elsewhere", () => { const seeThrough = (blocks: Record) => { const removed: { id: string; scope: string; directory?: string; expectUpdated?: string }[] = [] syncInternals.readBlock = async (scope, id) => - blocks[id] - ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) - : undefined + blocks[id] ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) : undefined syncInternals.removeBlock = async (scope, id, directory, expectUpdated) => { removed.push({ id, scope, directory, expectUpdated }) return true @@ -1152,7 +1143,11 @@ describe("reaping blocks archived elsewhere", () => { const removed = seeThrough({ split: { updated: "2026-05-01T00:00:00.000Z" } }) listResponse = [ // The live primary. - { id: "mem-primary", memory: "kept", metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" } }, + { + id: "mem-primary", + memory: "kept", + metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" }, + }, // The extra, archived at push time. archived("split", { archived_at: "2026-06-01T00:00:00.000Z" }), ] @@ -1351,7 +1346,11 @@ describe("hydrate", () => { test("drops archived records", async () => { listResponse = [ { id: "1", memory: "live", metadata: { source: MIRROR_SOURCE, block_id: "live", block_scope: "global" } }, - { id: "2", memory: "gone", metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" } }, + { + id: "2", + memory: "gone", + metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["live"]) @@ -1360,7 +1359,11 @@ describe("hydrate", () => { test("drops project records belonging to another workspace", async () => { listResponse = [ { id: "1", memory: "g", metadata: { source: MIRROR_SOURCE, block_id: "g", block_scope: "global" } }, - { id: "2", memory: "other", metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" } }, + { + id: "2", + memory: "other", + metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["g"]) @@ -1434,7 +1437,11 @@ describe("archiveBlock", () => { await mirrorBlock(b) captured = [] listResponse = [ - { id: "mem-archive", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" } }, + { + id: "mem-archive", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" }, + }, ] await archiveBlock("global", "to-archive") @@ -1572,6 +1579,26 @@ describe("backfill", () => { block({ id: `${prefix}/${i}`, content: `Block ${prefix} ${i} describes a warehouse convention.` }), ) + test("every block stored in the workspace reports workspace_memory_contributed with the workspace id", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + createResult = [{ id: "mem-contrib" }] + // Project-scope blocks are the ones bound to the workspace; global memory is the account's. + const bound = blocks("contrib", 2).map((b) => ({ ...b, scope: "project" })) + const result = await backfill(bound, BINDING as any) + expect(result.ok).toBe(2) + const contributed = events.filter((e) => e.type === "workspace_memory_contributed") + expect(contributed).toHaveLength(2) + expect(contributed[0]).toEqual(expect.objectContaining({ workspace_id: 42, block_count: 1 })) + } finally { + spy.mockRestore() + } + }) + test("reads the record set once for the whole sweep, not once per block", async () => { // Every block in a first bind is an index miss, so resolving each through // its own lookup made a bind cost one full fetch per block. @@ -1748,7 +1775,11 @@ describe("binding changes", () => { // 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, projectPath: dir, linkedAt: 3 }, + { awaitBackfill: true, seed: false }, + ) } return BINDING as any } @@ -1779,7 +1810,11 @@ describe("refresh racing a relink", () => { 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, + { awaitBackfill: true, seed: false }, + ) release?.() const result = await pending // Superseded: neither its read nor the prior overlay belongs to the new binding. @@ -1803,7 +1838,11 @@ describe("overlay invalidation", () => { 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) @@ -1846,7 +1885,11 @@ describe("epoch bracketing and scope", () => { 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, + { awaitBackfill: true, seed: false }, + ) return BINDING as any } return { ...BINDING, datamateId: 46 } as any @@ -1868,10 +1911,18 @@ describe("epoch bracketing and scope", () => { 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + other, + { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, + { awaitBackfill: true, 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 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + mine, + { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) }) @@ -1930,9 +1981,7 @@ describe("session isolation and turn behaviour", () => { // The caller's enclosing block runs on EVERY user turn, not once per // session. Without idempotence a 20-turn conversation costs 40 requests, // and the first injection of every turn blocks on them. - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) await hydrate(SES) await hydrate(SES) @@ -1970,9 +2019,7 @@ describe("session isolation and turn behaviour", () => { }) test("overlayBlocks returns a copy, so a caller cannot corrupt the cache", async () => { - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) overlayBlocks(SES).length = 0 expect(overlayBlocks(SES).length).toBe(1) @@ -2031,9 +2078,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(1) }) @@ -2047,9 +2092,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(0) }) }) @@ -2093,7 +2136,11 @@ describe("sync state", () => { listResponse = [note("a", "Naming conventions", "2026-02-01T00:00:00.000Z"), note("c", "Cost alerts")] await refresh(`${SES}-state-3`, dir) const entity = await memoryState(dir, (e) => e.changes !== null) - expect(entity?.changes).toEqual({ added: ["Cost alerts"], removed: ["Warehouse sizing"], updated: ["Naming conventions"] }) + expect(entity?.changes).toEqual({ + added: ["Cost alerts"], + removed: ["Warehouse sizing"], + updated: ["Naming conventions"], + }) expect(entity?.count).toBe(2) }) @@ -2226,12 +2273,18 @@ describe("sync state", () => { // project is relinked. let release!: () => void const gate = new Promise((r) => (release = r)) - const holder = SyncState.record(dir, BINDING.datamateId, "memory", {}, { - isCurrent: async () => { - await gate - return false + const holder = SyncState.record( + dir, + BINDING.datamateId, + "memory", + {}, + { + isCurrent: async () => { + await gate + return false + }, }, - }) + ) listResponse = [] await refresh(`${SES}-state-late-2`, dir) // Unlink: every binding epoch moves, and the queued "nothing left" record is now stale. diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts index 52a2104d1b..3f00af02bd 100644 --- a/packages/opencode/test/altimate/workspace/starter.test.ts +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -1,6 +1,7 @@ // altimate_change - new file -import { describe, expect, test } from "bun:test" -import { renderStarter, wrapStarter } from "../../../src/altimate/workspace/starter" +import { describe, expect, spyOn, test } from "bun:test" +import { renderStarter, trackStarterShown, wrapStarter } from "../../../src/altimate/workspace/starter" +import { Telemetry } from "../../../src/altimate/telemetry" import type { WorkspaceContents } from "../../../src/altimate/workspace/contents" const full: WorkspaceContents = { @@ -46,7 +47,12 @@ describe("renderStarter", () => { }) test("says what an empty or unsynced workspace means, and suggests only what it can back", () => { - const empty = renderStarter("Acme", { skills: [], integrations: [], memoryEnabled: false, knowledge: { kind: "off" } }) + const empty = renderStarter("Acme", { + skills: [], + integrations: [], + memoryEnabled: false, + knowledge: { kind: "off" }, + }) expect(empty.lines).toEqual([ "Skills: none yet. Add them in the workspace and they sync here.", "Knowledge: off for this workspace.", @@ -110,3 +116,38 @@ describe("wrapStarter", () => { expect(wrapStarter("a\n\nb c", 80)).toBe("a\n\nb c") }) }) + +describe("starter_shown telemetry", () => { + test("reports what the workspace provides as counts, and unknowns as zero", () => { + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + trackStarterShown(5, full) + expect(events).toEqual([ + expect.objectContaining({ + type: "starter_shown", + workspace_id: 5, + skills: Array.isArray(full.skills) ? full.skills.length : 0, + integrations: Array.isArray(full.integrations) ? full.integrations.length : 0, + knowledge: full.knowledge?.kind ?? "unknown", + memory_enabled: full.memoryEnabled === true, + }), + ]) + events.length = 0 + trackStarterShown(6, { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) + expect(events[0]).toEqual( + expect.objectContaining({ + workspace_id: 6, + skills: 0, + integrations: 0, + knowledge: "unknown", + memory_enabled: false, + }), + ) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/sync-state.test.ts b/packages/opencode/test/altimate/workspace/sync-state.test.ts index c4e275e6d9..9796884a4d 100644 --- a/packages/opencode/test/altimate/workspace/sync-state.test.ts +++ b/packages/opencode/test/altimate/workspace/sync-state.test.ts @@ -61,7 +61,11 @@ describe("diff", () => { }) test("identical sets have no changes", () => { - expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ added: [], removed: [], updated: [] }) + expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ + added: [], + removed: [], + updated: [], + }) }) test("an id named like an inherited property is still an item", () => { @@ -75,7 +79,13 @@ describe("record", () => { await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha"), b: skill("Beta") } }) const state = await SyncState.read(project, 7) - expect(state?.entities.skills).toMatchObject({ kind: "skills", status: "ok", count: 2, lastChangedAt: null, changes: null }) + expect(state?.entities.skills).toMatchObject({ + kind: "skills", + status: "ok", + count: 2, + lastChangedAt: null, + changes: null, + }) expect(state?.entities.skills?.lastCheckedAt).toBeNumber() expect(state?.entities.skills?.lastSucceededAt).toBe(state!.entities.skills!.lastCheckedAt) expect(published).toEqual([]) @@ -196,7 +206,13 @@ describe("record", () => { test("a late result from a previous binding is dropped", async () => { await SyncState.record(project, 8, "memory", { items: { m1: skill("New workspace block") } }) - await SyncState.record(project, 7, "memory", { error: "could not load workspace memory" }, { isCurrent: () => false }) + await SyncState.record( + project, + 7, + "memory", + { error: "could not load workspace memory" }, + { isCurrent: () => false }, + ) const state = await SyncState.read(project, 8) expect(state?.entities.memory).toMatchObject({ status: "ok", count: 1 }) @@ -298,3 +314,36 @@ describe("forget", () => { expect((await SyncState.read(project, 7))?.entities.memory?.count).toBe(1) }) }) + +describe("workspace_sync_failed telemetry", () => { + test("a kind flipping to error reports once, with a reason class; a repeat does not", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + await SyncState.record(project, 9, "memory", { error: "401 unauthorized" }) + expect(events).toEqual([ + expect.objectContaining({ type: "workspace_sync_failed", workspace_id: 9, kind: "memory", reason: "auth" }), + ]) + await SyncState.record(project, 9, "memory", { error: "still unauthorized" }) // already in error: no new row + expect(events).toHaveLength(1) + await SyncState.record(project, 9, "memory", { items: { a: skill("Alpha") } }) // back to ok + await SyncState.record(project, 9, "memory", { error: "Cannot reach https://api: ECONNREFUSED" }) + expect(events).toHaveLength(2) + expect(events[1]).toEqual(expect.objectContaining({ kind: "memory", reason: "network" })) + } finally { + spy.mockRestore() + } + }) + + test("syncFailureReason classifies the messages the sync produces", () => { + expect(SyncState.syncFailureReason("Request failed with status 403")).toBe("auth") + expect(SyncState.syncFailureReason("Please sign in again")).toBe("auth") + expect(SyncState.syncFailureReason("Workspace not found (404)")).toBe("not_found") + expect(SyncState.syncFailureReason("Request to x timed out after 15s")).toBe("network") + expect(SyncState.syncFailureReason("The server returned no usable id")).toBe("other") + expect(SyncState.syncFailureReason(undefined)).toBe("other") + }) +}) diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index dddceea31f..c790e621c7 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -2,7 +2,7 @@ // Covers the injection-side merge of workspace memory into local memory: // which blocks survive, and the guard that keeps a cloud-sourced training // block from writing to the local store. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -141,6 +141,30 @@ describe("the memory refresh tool", () => { expect(res.output).toContain("2 block(s)") }) + test("a block from the bound workspace's memory is injected under the workspace scope and counted", async () => { + const { Telemetry } = await import("../../src/altimate/telemetry") + listResponse = [remote("ws/one", "WORKSPACE BLOCK")] + await hydrate(SES) + expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["ws/one"]) + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + Telemetry.clearWorkspaceUse(SES) + const injected = await MemoryPrompt.inject(8000, { sessionID: SES }) + expect(injected).toContain("WORKSPACE BLOCK") + const injection = events.find((e) => e.type === "memory_injection") + expect(injection).toBeDefined() + expect(injection.scopes_used).toContain("workspace") + expect(injection.workspace_blocks).toBe(1) + expect(Telemetry.workspaceUseOf(SES).memory).toBe(1) + } finally { + spy.mockRestore() + Telemetry.clearWorkspaceUse(SES) + } + }) + test("says the workspace is unreachable rather than reporting an empty reload", async () => { listResponse = [remote("keep/one", "KEEP")] await hydrate(SES) @@ -270,7 +294,11 @@ describe("on-demand reload", () => { const { count, ok } = await refresh(SES) expect(ok).toBe(true) expect(count).toBe(2) - expect(overlayBlocks(SES).map((b) => b.id).sort()).toEqual(["warehouse/one", "warehouse/two"]) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["warehouse/one", "warehouse/two"]) }) test("a failed refresh keeps the memory the session already had", async () => { @@ -303,7 +331,7 @@ describe("on-demand reload", () => { await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["keep/me"]) - memoryEnabledCache.clear() // past the 60s positive-cache TTL + memoryEnabledCache.clear() // past the 60s positive-cache TTL const originalFetch = globalThis.fetch globalThis.fetch = (async (_i?: unknown, _n?: unknown) => { throw new Error("workspace unreachable") @@ -341,21 +369,22 @@ describe("on-demand reload", () => { firstCall = false await gate return new Response(JSON.stringify([remote("stale/one", "STALE")]), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }) } return originalFetch(input, init) }) as typeof fetch try { - void hydrate(SES) // A — stalls on the gate + void hydrate(SES) // A — stalls on the gate await new Promise((r) => setTimeout(r, 20)) listResponse = [remote("fresh/one", "FRESH")] - const res = await refresh(SES) // B — completes first + const res = await refresh(SES) // B — completes first expect(res.status).toBe("loaded") expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) - release?.() // A resolves late + release?.() // A resolves late await new Promise((r) => setTimeout(r, 50)) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) } finally { diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts index 3ede7dcf73..822a2930e8 100644 --- a/packages/opencode/test/tool/skill-source.test.ts +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -18,6 +18,13 @@ describe("classifySkillSource", () => { expect(classifySkillSource("C:\\Users\\x\\.altimate\\builtin\\dbt-analyze\\SKILL.md")).toBe("builtin") }) + test("skills synced from the bound workspace (the managed _workspace folder) → workspace", () => { + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/dbt-review/SKILL.md")).toBe("workspace") + expect(classifySkillSource("C:\\work\\repo\\.altimate-code\\skill\\_workspace\\x\\SKILL.md")).toBe("workspace") + // a project skill that merely mentions the word is still the project's + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace-notes/SKILL.md")).toBe("project") + }) + test("a third-party skill under some other node_modules is NOT tagged Altimate", () => { expect(classifySkillSource("/work/repo/node_modules/some-other-pkg/skills/x/SKILL.md")).toBe("project") }) From 1ef15a496c0ff1e98cc050a632d90910e2bf498f Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:23:44 +0800 Subject: [PATCH 03/19] fix: track workspace integration calls where MCP tools run MCP tools never pass through Tool.define, so the via_workspace row and the integrations count now come from the MCP execution path, keyed on the client name; the id-prefix test in tool.ts is gone, so datamate_manager no longer counts. The TUI publish event carries workspace_id like the CLI and serve paths. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/workspace/tool-call-telemetry.ts | 49 +++++++++++++ .../src/plugin/tui/altimate/skill-ops.tsx | 1 + packages/opencode/src/session/prompt.ts | 28 +++++++- packages/opencode/src/tool/tool.ts | 14 ---- .../workspace/tool-call-telemetry.test.ts | 71 +++++++++++++++++++ 5 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/tool-call-telemetry.ts create mode 100644 packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts new file mode 100644 index 0000000000..dc9351682d --- /dev/null +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -0,0 +1,49 @@ +// altimate_change — tool_call rows for the bound workspace's integration calls. +import { Telemetry } from "@/altimate/telemetry" +import { mcpToolSource } from "@/altimate/tool-source" + +/** True when the MCP client serving a tool is the bound workspace's datamate server. */ +export function isWorkspaceClient(clientName: string): boolean { + return mcpToolSource(clientName) === "altimate" +} + +/** + * Track one MCP call that went through the bound workspace. MCP tools never pass through the + * `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration + * call becomes a `tool_call` row; a successful one also counts as an integration use for the + * session. Third-party MCP servers stay untracked and registry tools (`datamate_manager` + * included) never reach here. Returns whether the call was tracked. + */ +export function trackWorkspaceToolCall(input: { + key: string + clientName: string + sessionID: string + messageID: string + startedAt: number + outcome: "success" | "error" + error?: unknown +}): boolean { + if (!isWorkspaceClient(input.clientName)) return false + try { + if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") + const message = input.error instanceof Error ? input.error.message : String(input.error) + Telemetry.track({ + type: "tool_call", + timestamp: Date.now(), + session_id: input.sessionID, + message_id: input.messageID, + tool_name: input.key, + tool_type: "mcp", + tool_category: Telemetry.categorizeToolName(input.key, "mcp"), + status: input.outcome, + via_workspace: true, + duration_ms: Date.now() - input.startedAt, + sequence_index: 0, + previous_tool: null, + ...(input.error !== undefined && { error: Telemetry.maskString(message).slice(0, 500) }), + }) + } catch { + // telemetry never surfaces into the tool call + } + return true +} diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 3c7b47306d..d5b52efdc9 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -550,6 +550,7 @@ export async function publishFromPicker( skill_name: skillName, action: report.action, file_count: report.files, + workspace_id: report.datamateId, source: "tui", }) } catch {} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a6b18407c4..fba3197255 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -103,6 +103,7 @@ import { Config } from "../config/config" import { Tracer } from "../altimate/observability/tracing" // altimate_change start — stamp an authoritative tool source + humanized MCP title import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source" +import { trackWorkspaceToolCall } from "../altimate/workspace/tool-call-telemetry" // altimate_change — workspace integration rows // altimate_change end // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -2515,7 +2516,32 @@ export namespace SessionPrompt { ) // altimate_change end - const result = await execute(args, opts) + // altimate_change start — a workspace integration call is tracked here, where MCP tools run + const startedAt = Date.now() + let result + try { + result = await execute(args, opts) + } catch (err) { + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + startedAt, + outcome: "error", + error: err, + }) + throw err + } + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + startedAt, + outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", + }) + // altimate_change end await Plugin.trigger( "tool.execute.after", diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 9192980acf..c4bd52eca1 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -131,13 +131,6 @@ export type InferDef = : never // altimate_change start — telemetry helpers (ported from the fork's pre-1.17 Tool.define wrapper) -// altimate_change start — a tool whose id starts with the datamate prefix is an MCP tool of the bound -// workspace (mirrors ALTIMATE_MCP_PREFIXES in altimate/tool-source.ts: `datamate`, `datamates`, `datamate-…`). -function viaWorkspace(id: string): boolean { - return /^datamates?([_-]|$)/i.test(id) -} -// altimate_change end - function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: number, error: unknown) { try { const errorMsg = error instanceof Error ? error.message : String(error) @@ -151,8 +144,6 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: tool_type: "standard", tool_category: Telemetry.categorizeToolName(id, "standard"), status: "error", - // altimate_change — workspace integration call - ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: Date.now() - startTime, sequence_index: 0, previous_tool: null, @@ -186,9 +177,6 @@ function altimateTrackSuccess( try { const isSoftFailure = result.metadata?.success === false const durationMs = Date.now() - startTime - // altimate_change start — count successful calls to the bound workspace's integrations - if (!isSoftFailure && viaWorkspace(id)) Telemetry.noteWorkspaceUse(ctx.sessionID, "integrations") - // altimate_change end const toolCategory = Telemetry.categorizeToolName(id, "standard") // Skip success tool_call for high-volume/low-signal file tools; failures still tracked. if (isSoftFailure || toolCategory !== "file") { @@ -201,8 +189,6 @@ function altimateTrackSuccess( tool_type: "standard", tool_category: toolCategory, status: isSoftFailure ? "error" : "success", - // altimate_change — workspace integration call - ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: durationMs, sequence_index: 0, previous_tool: null, diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts new file mode 100644 index 0000000000..1258e9b24c --- /dev/null +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Telemetry } from "../../../src/altimate/telemetry" +import { isWorkspaceClient, trackWorkspaceToolCall } from "../../../src/altimate/workspace/tool-call-telemetry" + +describe("workspace integration tool calls", () => { + let rows: any[] + let spy: ReturnType + beforeEach(() => { + rows = [] + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e) + }) + Telemetry.clearWorkspaceUse("s1") + }) + afterEach(() => { + spy.mockRestore() + Telemetry.clearWorkspaceUse("s1") + }) + + const call = (clientName: string, outcome: "success" | "error", error?: unknown) => + trackWorkspaceToolCall({ + key: `${clientName}_jira_get_issue`, + clientName, + sessionID: "s1", + messageID: "m1", + startedAt: Date.now() - 5, + outcome, + error, + }) + + test("the datamate client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { + expect(call("datamate", "success")).toBe(true) + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual( + expect.objectContaining({ + type: "tool_call", + tool_name: "datamate_jira_get_issue", + tool_type: "mcp", + status: "success", + via_workspace: true, + session_id: "s1", + message_id: "m1", + }), + ) + expect(rows[0].duration_ms).toBeGreaterThanOrEqual(0) + expect("error" in rows[0]).toBe(false) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(1) + }) + + test("a failed call is a tool_call error row with a masked message and no integration use", () => { + expect(call("datamates", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) + expect(rows[0]).toEqual(expect.objectContaining({ status: "error", via_workspace: true, tool_type: "mcp" })) + expect(rows[0].error).not.toContain("/home/someone") + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + expect(call("datamate-acme", "error", "string failure")).toBe(true) + expect(rows[1].error).toContain("string failure") + }) + + test("other MCP clients are not tracked here, and a registry tool id is not a client", () => { + expect(call("github", "success")).toBe(false) + expect(call("filesystem", "error", new Error("x"))).toBe(false) + expect(rows).toHaveLength(0) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + // The registry tool `datamate_manager` runs through Tool.define, never through this path, and its + // id is not a datamate client name either. + expect(isWorkspaceClient("datamate_manager")).toBe(false) + expect(isWorkspaceClient("datamate")).toBe(true) + expect(isWorkspaceClient("Datamates")).toBe(true) + expect(isWorkspaceClient("datamate-acme")).toBe(true) + }) +}) From bb25f62e21f7cbc6b001fce42c8fb72d30cf7243 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:39:25 +0800 Subject: [PATCH 04/19] fix: stamp the workspace id at track time; keep the memory failure detail A serve process can move the context to another session before the batch is serialised, and the memory sync record flattened every failure to one text, so its reason class was always other. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 5 +++- .../src/altimate/workspace/memory-sync.ts | 29 ++++++++++++------- .../telemetry/workspace-context.test.ts | 8 ++--- .../altimate/workspace/memory-sync.test.ts | 4 ++- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 5f6939c07c..4bba2fdfee 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2044,7 +2044,6 @@ export namespace Telemetry { // still says how this CLI was launched (cli, tui, serve, datamates). client: "altimate-code", source: clientSource, - ...(workspaceId !== undefined && !("workspace_id" in fields) && { workspace_id: String(workspaceId) }), project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), // altimate_change — groups every event from one process launch. The onboarding funnel @@ -2401,6 +2400,10 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end + // altimate_change start — the bound workspace is read now, not at flush: a serve process can start + // another session's turn, and move the context, before this event is serialised. + if (workspaceId !== undefined && !("workspace_id" in event)) (event as any).workspace_id = workspaceId + // altimate_change end buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { buffer.shift() diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 696a9d1be4..200e302c81 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -261,6 +261,9 @@ async function memoryEnabled(binding: CachedBinding): Promise { return (await memoryStatus(binding)) === "enabled" } +/** A failed read says what failed, so the sync record can classify it. */ +type MemoryStatusOutcome = "enabled" | "disabled" | { status: "error"; error: string } + /** Three-way, because a read that cannot reach the service must not be reported * as "this workspace has no memory" — that reads as success while destroying * whatever the session already had. */ @@ -273,7 +276,7 @@ async function memoryStatus( * inherit a positive written under a previous account. */ fresh?: boolean } = {}, -): Promise<"enabled" | "disabled" | "error"> { +): Promise { const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId) if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled" try { @@ -289,7 +292,7 @@ async function memoryStatus( missingFromListWarned.add(binding.datamateId) log.warn("workspace missing from the workspace list; memory setting unknown", { workspace: binding.datamateId }) } - return "error" + return { status: "error", error: "workspace missing from the workspace list" } } missingFromListWarned.delete(binding.datamateId) if (match.memoryEnabled === undefined && !missingFieldWarned.has(binding.datamateId)) { @@ -316,7 +319,7 @@ async function memoryStatus( log.warn("could not confirm workspace memory setting", { err: String(err) }) // Not cached either way: a transient failure should neither disable the // mirror for a minute nor keep it enabled. - return "error" + return { status: "error", error: String(err) } } } @@ -1108,7 +1111,7 @@ export async function memoryEnabledForPoller(binding: CachedBinding): Promise<"e const ask = (async () => { try { const status = await memoryStatus(binding, { fresh: true }) - const answer = status === "error" ? ("unknown" as const) : status + const answer = typeof status === "string" ? status : ("unknown" as const) pollMemo.set(key, { at: Date.now(), status: answer }) return answer } finally { @@ -1242,8 +1245,10 @@ export async function backfill( 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 } + if (status !== "enabled") { + const gateReason = typeof status === "string" ? status : status.status + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason } + } const index = await readIndex() const { pending, skipped } = partitionPending(blocks, binding, index) @@ -1412,7 +1417,7 @@ type LoadOutcome = ( } | { status: "unlinked"; /** The server confirmed it, rather than a lookup failing. */ confirmed: boolean } | { status: "disabled" } - | { status: "error" } + | { status: "error"; /** What failed, for the sync record and its reason class. */ error: string } ) & { epoch?: string dir?: string | null @@ -1454,12 +1459,12 @@ async function loadWorkspaceMemory(directory?: string): Promise { stable = before === epoch } vouched = epoch - if (!stable) return { status: "error", epoch, dir, ...stamp } + if (!stable) return { status: "error", error: "the workspace link changed while loading", epoch, dir, ...stamp } if (!binding) return { status: "unlinked", confirmed, epoch, dir, ...stamp } const datamateId = binding.datamateId resolved = datamateId const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error", epoch, dir, datamateId, ...stamp } + if (typeof enabled !== "string") return { status: "error", error: enabled.error, epoch, dir, datamateId, ...stamp } if (enabled === "disabled") return { status: "disabled", epoch, dir, datamateId, ...stamp } const ownProjectKey = projectKeyFor(binding) @@ -1503,7 +1508,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: vouched, dir, datamateId: resolved, ...stamp } + return { status: "error", error: String(err), epoch: vouched, dir, datamateId: resolved, ...stamp } } } @@ -1549,7 +1554,9 @@ function recordSyncState(outcome: LoadOutcome): void { if (retire) return SyncState.forget(dir, "memory", { isCurrent, before: outcome.observedAt }) if (outcome.datamateId === undefined) return const result = - outcome.status === "loaded" ? { items: outcome.items } : { error: "could not load workspace memory" } + outcome.status === "loaded" + ? { items: outcome.items } + : { error: `could not load workspace memory: ${"error" in outcome ? outcome.error : outcome.status}` } return SyncState.record(dir, outcome.datamateId, "memory", result, { isCurrent, scope: outcome.scope, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 60e9d45507..65827e64a3 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -79,16 +79,16 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) + // Another session's turn moves the context before the batch is serialised (a serve process): + // the rows above keep the workspace they were tracked under. + Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) + Telemetry.track(startup("unbound")) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") expect(bound.data.baseData.properties.workspace_id).toBe("42") expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's - - Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) - Telemetry.track(startup("unbound")) - await Telemetry.flush() const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) }) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index d991cb2155..1f1ad25578 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -2237,7 +2237,9 @@ describe("sync state", () => { listFails = false } const failed = await memoryState(dir, (e) => e.status === "error") - expect(failed).toMatchObject({ status: "error", count: 1, error: "could not load workspace memory" }) + expect(failed).toMatchObject({ status: "error", count: 1 }) + // The record keeps what failed, so the sync-failure reason class is real rather than "other". + expect(failed?.error).toMatch(/^could not load workspace memory: /) // "memory loaded Xm ago" reads the last success, which the failure did not move. expect(failed?.lastSucceededAt).toBe(loaded!.lastSucceededAt) }) From 7a79820e83112ff4ffbf13c1771a6651dda92e91 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:52:53 +0800 Subject: [PATCH 05/19] fix: stamp the workspace of the event's own session A serve process runs sessions side by side, so the process-wide context can belong to another session's turn when an event is tracked; the workspace is now kept per session and read by the event's session_id. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 28 +++++++++++++++++-- .../telemetry/workspace-context.test.ts | 28 +++++++++++++++++-- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 4bba2fdfee..28369505e6 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1960,6 +1960,12 @@ export namespace Telemetry { // the session, first-use, skill, memory, tool and outcome rows join the workspace without each // emit site knowing about it. let workspaceId: number | undefined + // altimate_change start — the workspace of every session that set the context, by session: a serve + // process runs sessions side by side, so the singleton above can belong to another session's turn + // when an event is tracked. Insertion-ordered; the oldest entry goes past the cap. + const sessionWorkspaces = new Map() + const MAX_SESSION_WORKSPACES = 256 + // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 let initPromise: Promise | undefined @@ -2247,6 +2253,15 @@ export namespace Telemetry { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId + // altimate_change start — remembered per session for the stamping in track() + if (opts.sessionId) { + sessionWorkspaces.delete(opts.sessionId) + sessionWorkspaces.set(opts.sessionId, opts.workspaceId) + if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { + sessionWorkspaces.delete(sessionWorkspaces.keys().next().value!) + } + } + // altimate_change end } export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { @@ -2400,9 +2415,15 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end - // altimate_change start — the bound workspace is read now, not at flush: a serve process can start - // another session's turn, and move the context, before this event is serialised. - if (workspaceId !== undefined && !("workspace_id" in event)) (event as any).workspace_id = workspaceId + // altimate_change start — the bound workspace is read now, not at flush, and for the event's own + // session: a serve process runs sessions side by side, so the process-wide context can belong to + // another session's turn. A row with no session takes the current context; a row naming a session + // that never set the context gains no key rather than another session's. + if (!("workspace_id" in event)) { + const own = + typeof eventSession === "string" && eventSession !== "" ? sessionWorkspaces.get(eventSession) : workspaceId + if (own !== undefined) (event as any).workspace_id = own + } // altimate_change end buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { @@ -2601,6 +2622,7 @@ export namespace Telemetry { sessionId = "" projectId = "" workspaceId = undefined + sessionWorkspaces.clear() // altimate_change machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 65827e64a3..86bba75795 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -11,10 +11,18 @@ describe("workspace id on the telemetry context", () => { let homedirSpy: ReturnType let savedCs: string | undefined let savedHome: string | undefined + // Either disable flag wins over the connection string; cleared for the real init below and restored. + const DISABLE_FLAGS = ["ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY"] as const + let savedFlags: Record beforeEach(() => { home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ws-")) savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedFlags = {} + for (const flag of DISABLE_FLAGS) { + savedFlags[flag] = process.env[flag] + delete process.env[flag] + } savedHome = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = home process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = @@ -36,6 +44,10 @@ describe("workspace id on the telemetry context", () => { else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs if (savedHome === undefined) delete process.env.OPENCODE_TEST_HOME else process.env.OPENCODE_TEST_HOME = savedHome + for (const flag of DISABLE_FLAGS) { + if (savedFlags[flag] === undefined) delete process.env[flag] + else process.env[flag] = savedFlags[flag] + } rmSync(home, { recursive: true, force: true }) }) @@ -79,16 +91,28 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) - // Another session's turn moves the context before the batch is serialised (a serve process): - // the rows above keep the workspace they were tracked under. + // Another session's turn moves the context (a serve process) before the batch is serialised, and + // before s1 tracks its next rows: every row still carries the workspace of its own session. Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) Telemetry.track(startup("unbound")) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s3" }) // never set the context + Telemetry.setContext({ sessionId: "s4", projectId: "p1", workspaceId: 9 }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") expect(bound.data.baseData.properties.workspace_id).toBe("42") expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + const unlinked = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") + expect(unlinked.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ + ["s1", "42"], + ["s2", undefined], + ["s3", undefined], + ["s2", undefined], + ]) const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) }) From 0eec72ee634dec6b14834449a9cec9633d523df8 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:33:48 +0800 Subject: [PATCH 06/19] fix: address cubic's review of the usage rows Session entries end with the turn, not at shutdown; the engine key counts only in a bound turn; only project cloud blocks are workspace memory; skill source scoped to this project; unknown starter contents omitted; documents counter removed; failure text keeps the status and is redacted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 31 +++++++++------ .../src/altimate/workspace/memory-sync.ts | 32 +++++++++++---- .../src/altimate/workspace/starter.ts | 7 ++-- .../src/altimate/workspace/sync-state.ts | 2 +- .../altimate/workspace/tool-call-telemetry.ts | 19 +++++---- packages/opencode/src/memory/prompt.ts | 11 +++--- packages/opencode/src/session/prompt.ts | 7 ++-- packages/opencode/src/tool/skill.ts | 17 ++++++-- .../telemetry/workspace-context.test.ts | 31 ++++++++++++--- .../test/altimate/workspace/starter.test.ts | 17 +++----- .../workspace/tool-call-telemetry.test.ts | 39 ++++++++++++++----- .../test/memory/overlay-merge.test.ts | 15 ++++++- .../opencode/test/tool/skill-source.test.ts | 6 +++ 13 files changed, 160 insertions(+), 74 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 28369505e6..9443f5bfae 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -260,7 +260,6 @@ export namespace Telemetry { duration_ms: number // altimate_change — what of the bound workspace this session used (see noteWorkspaceUse) workspace_skills_used?: number - workspace_documents_used?: number workspace_memory_blocks?: number workspace_integration_calls?: number } @@ -609,10 +608,11 @@ export namespace Telemetry { timestamp: number session_id: string workspace_id: number - skills: number - integrations: number + /** Absent when the starter could not tell (unsynced, unreadable, timed out). */ + skills?: number + integrations?: number knowledge: string - memory_enabled: boolean + memory_enabled?: boolean } | { /** A memory block was written back to the bound workspace's cloud memory. */ @@ -953,7 +953,7 @@ export namespace Telemetry { duration_ms: number /** Last tool category the agent used (or "none") */ last_tool_category: string - // altimate_change — true when the session had used workspace skills, memory, documents or integrations + // altimate_change — true when the session had used workspace skills, memory or integrations workspace_context_used?: boolean } // task intent classification for understanding DE problem distribution @@ -1962,9 +1962,10 @@ export namespace Telemetry { let workspaceId: number | undefined // altimate_change start — the workspace of every session that set the context, by session: a serve // process runs sessions side by side, so the singleton above can belong to another session's turn - // when an event is tracked. Insertion-ordered; the oldest entry goes past the cap. + // when an event is tracked. An entry lives from the session's turn start to its end (endSession); + // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() - const MAX_SESSION_WORKSPACES = 256 + const MAX_SESSION_WORKSPACES = 1024 // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2233,20 +2234,29 @@ export namespace Telemetry { // emit sites note what they used; session_end reports the counts and task_outcome_signal // whether any workspace context was in play. Keyed by session so a serve process with several // sessions keeps them apart; cleared when the session ends. - export type WorkspaceUse = { skills: number; documents: number; memory: number; integrations: number } + export type WorkspaceUse = { skills: number; memory: number; integrations: number } const workspaceUse = new Map() export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { if (!sessionId || n <= 0) return - const use = workspaceUse.get(sessionId) ?? { skills: 0, documents: 0, memory: 0, integrations: 0 } + const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] += n workspaceUse.set(sessionId, use) } export function workspaceUseOf(sessionId: string): WorkspaceUse { - return { skills: 0, documents: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } } export function clearWorkspaceUse(sessionId: string) { workspaceUse.delete(sessionId) } + /** The workspace a session's turn bound, or undefined when unbound or unknown. */ + export function workspaceOf(sessionId: string): number | undefined { + return sessionWorkspaces.get(sessionId) + } + /** A session's turn ended: its use counts are read out by then, and its workspace entry goes. */ + export function endSession(sessionId: string) { + workspaceUse.delete(sessionId) + sessionWorkspaces.delete(sessionId) + } // altimate_change end export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { @@ -2622,7 +2632,6 @@ export namespace Telemetry { sessionId = "" projectId = "" workspaceId = undefined - sessionWorkspaces.clear() // altimate_change machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 200e302c81..d6735f4514 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -35,7 +35,8 @@ import { type CachedBinding, } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" -import { WorkspaceApi } from "./api-client" +import { WorkspaceApi, WorkspaceApiError } from "./api-client" +import { redact } from "@/altimate/debug/report" // altimate_change — failure text in the sync record import { AltimateApi } from "@/altimate/api/client" import { LIST_LIMIT, @@ -264,6 +265,16 @@ async function memoryEnabled(binding: CachedBinding): Promise { /** A failed read says what failed, so the sync record can classify it. */ type MemoryStatusOutcome = "enabled" | "disabled" | { status: "error"; error: string } +// altimate_change start — what failed, for the persisted sync record: the HTTP status when the API +// answered one (so the reason class sees it whatever the detail says), redacted of secrets, URLs +// and the user's identity before it is written. +function failureText(err: unknown): string { + const status = err instanceof WorkspaceApiError ? err.status : undefined + const base = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + return redact(status !== undefined ? `HTTP ${status} ${base}` : base) +} +// altimate_change end + /** Three-way, because a read that cannot reach the service must not be reported * as "this workspace has no memory" — that reads as success while destroying * whatever the session already had. */ @@ -319,7 +330,7 @@ async function memoryStatus( log.warn("could not confirm workspace memory setting", { err: String(err) }) // Not cached either way: a transient failure should neither disable the // mirror for a minute nor keep it enabled. - return { status: "error", error: String(err) } + return { status: "error", error: failureText(err) } } } @@ -677,6 +688,8 @@ async function push( binding: CachedBinding | null, known?: KnownRecords, directory?: string, + /** The session that wrote the block, captured before the queue; the context's when absent. */ + sessionID?: string, ): Promise { const key = indexKey({ scope: block.scope, @@ -760,7 +773,7 @@ async function push( } await MemoryApi.update(match, block.content, metadata) await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding) + if (binding) trackContributed(binding, sessionID) return "stored" } @@ -795,17 +808,17 @@ async function push( log.warn("archiving an extra record produced by one create", { id: block.id, memoryId: extra }) await MemoryApi.update(extra, "", { ...metadata, archived: "true", archived_at: new Date().toISOString() }) } - if (binding) trackContributed(binding) + if (binding) trackContributed(binding, sessionID) return "stored" } // altimate_change start — a successful write-back is the workspace getting something back -function trackContributed(binding: CachedBinding) { +function trackContributed(binding: CachedBinding, sessionID?: string) { try { Telemetry.track({ type: "workspace_memory_contributed", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + session_id: sessionID ?? Telemetry.getContext().sessionId, workspace_id: binding.datamateId, block_count: 1, }) @@ -890,6 +903,9 @@ export async function flushPendingMirrors(timeoutMs = 30_000): Promise { * disabled. */ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promise { if (!isEnabled()) return + // altimate_change — the writer's session, read before the queue: the context can be another + // session's by the time the mirror lands. + const sessionID = Telemetry.getContext().sessionId // Queued BEFORE the binding lookup, not after. Both are async, so resolving // them first let two operations on one block reach `serialize` in the // opposite order to the writes that triggered them. @@ -903,7 +919,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi const binding = await currentBinding(directory) if (!binding) return if (!(await memoryEnabled(binding))) return - await push(block, binding, undefined, directory) + await push(block, binding, undefined, directory, sessionID) }) return tracked(task) } @@ -1508,7 +1524,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", error: String(err), epoch: vouched, dir, datamateId: resolved, ...stamp } + return { status: "error", error: failureText(err), epoch: vouched, dir, datamateId: resolved, ...stamp } } } diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 67e3e654b0..5b7680426a 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -154,10 +154,11 @@ export function trackStarterShown(workspaceId: number, contents: WorkspaceConten timestamp: Date.now(), session_id: Telemetry.getContext().sessionId, workspace_id: workspaceId, - skills: Array.isArray(contents.skills) ? contents.skills.length : 0, - integrations: Array.isArray(contents.integrations) ? contents.integrations.length : 0, + // Unknown is left out, not reported as none: the starter's own rule. + ...(Array.isArray(contents.skills) ? { skills: contents.skills.length } : {}), + ...(Array.isArray(contents.integrations) ? { integrations: contents.integrations.length } : {}), knowledge: contents.knowledge?.kind ?? "unknown", - memory_enabled: contents.memoryEnabled === true, + ...(typeof contents.memoryEnabled === "boolean" ? { memory_enabled: contents.memoryEnabled } : {}), }) } catch { // telemetry never blocks the starter diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index ac428534d1..c8dd5819dc 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -279,7 +279,7 @@ function chain(file: string, work: () => Promise, kind: SyncKind): Promise /** The reason class of a sync error message, for workspace_sync_failed. */ export function syncFailureReason(error: string | undefined): "auth" | "not_found" | "network" | "other" { const e = (error ?? "").toLowerCase() - if (/\b(401|403)\b|unauthori[sz]ed|forbidden|sign(ed)? in|credential/.test(e)) return "auth" + if (/\b(401|403)\b|unauthori[sz]ed|forbidden|\bsign(ed)? in\b|credential/.test(e)) return "auth" if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" return "other" diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index dc9351682d..77217f46a6 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -1,18 +1,20 @@ // altimate_change — tool_call rows for the bound workspace's integration calls. import { Telemetry } from "@/altimate/telemetry" -import { mcpToolSource } from "@/altimate/tool-source" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { redact } from "@/altimate/debug/report" -/** True when the MCP client serving a tool is the bound workspace's datamate server. */ +/** True when the MCP client serving a tool is the engine the workspace overlay manages. */ export function isWorkspaceClient(clientName: string): boolean { - return mcpToolSource(clientName) === "altimate" + return clientName === DATAMATE_KEY } /** - * Track one MCP call that went through the bound workspace. MCP tools never pass through the - * `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration + * Track one MCP call that went through the bound workspace's engine. MCP tools never pass through + * the `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration * call becomes a `tool_call` row; a successful one also counts as an integration use for the - * session. Third-party MCP servers stay untracked and registry tools (`datamate_manager` - * included) never reach here. Returns whether the call was tracked. + * session. Only the managed engine client (`datamate`) in a session whose turn is bound counts: + * a standalone `datamate-` server, or the key in an unbound session, is not the workspace's. + * Returns whether the call was tracked. */ export function trackWorkspaceToolCall(input: { key: string @@ -24,6 +26,7 @@ export function trackWorkspaceToolCall(input: { error?: unknown }): boolean { if (!isWorkspaceClient(input.clientName)) return false + if (Telemetry.workspaceOf(input.sessionID) === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") const message = input.error instanceof Error ? input.error.message : String(input.error) @@ -40,7 +43,7 @@ export function trackWorkspaceToolCall(input: { duration_ms: Date.now() - input.startedAt, sequence_index: 0, previous_tool: null, - ...(input.error !== undefined && { error: Telemetry.maskString(message).slice(0, 500) }), + ...(input.error !== undefined && { error: redact(Telemetry.maskString(message)).slice(0, 500) }), }) } catch { // telemetry never surfaces into the tool call diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 984a4b6324..61590a9033 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -246,8 +246,9 @@ export namespace MemoryPrompt { injectedCount++ injectedTraining.push(item.block) scopesSeen.add(item.block.scope) - // altimate_change start — a block from the bound workspace's cloud memory counts as workspace scope - if (isRemote(item.block)) { + // altimate_change start — a project-scope block from the bound workspace's cloud memory counts as + // workspace scope; a global cloud block is the account's and applies everywhere + if (isRemote(item.block) && item.block.scope === "project") { scopesSeen.add("workspace") workspaceBlocks++ } @@ -272,7 +273,7 @@ export namespace MemoryPrompt { injectedCount++ scopesSeen.add(item.block.scope) // altimate_change start — as above, for memory blocks - if (isRemote(item.block)) { + if (isRemote(item.block) && item.block.scope === "project") { scopesSeen.add("workspace") workspaceBlocks++ } @@ -310,9 +311,7 @@ export namespace MemoryPrompt { // altimate_change start — workspace memory use, for memory_injection and the session's summary rows ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) - if (workspaceBlocks > 0) { - Telemetry.noteWorkspaceUse(ctx?.sessionID ?? Telemetry.getContext().sessionId, "memory", workspaceBlocks) - } + if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks) // altimate_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fba3197255..61053a03fe 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -110,13 +110,12 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" -/** The session's workspace use, as session_end fields; counts are cleared with the read. */ +/** The session's workspace use, as session_end fields; the read ends the session's telemetry entries. */ function workspaceUseFields(sessionID: string) { const use = Telemetry.workspaceUseOf(sessionID) - Telemetry.clearWorkspaceUse(sessionID) + Telemetry.endSession(sessionID) return { workspace_skills_used: use.skills, - workspace_documents_used: use.documents, workspace_memory_blocks: use.memory, workspace_integration_calls: use.integrations, } @@ -2132,7 +2131,7 @@ export namespace SessionPrompt { step_count: step, duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", - // altimate_change — whether workspace skills, memory, documents or integrations were in play + // altimate_change — whether workspace skills, memory or integrations were in play workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 066331aa2a..2fa95a83c4 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -19,11 +19,16 @@ import { selectSkillsWithLLM } from "../altimate/skill-selector" import { Telemetry } from "../altimate/telemetry" import os from "os" import { isBuiltinSkillLocation } from "../altimate/skill-source" +import { Instance } from "../project/instance" const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge -export function classifySkillSource(location: string): "builtin" | "global" | "project" | "workspace" { +export function classifySkillSource( + location: string, + /** This project's root; with it, only its own managed folder is the workspace's. */ + projectRoot?: string, +): "builtin" | "global" | "project" | "workspace" { // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. const normalized = location.replace(/\\/g, "/") // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's @@ -34,8 +39,12 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart if (isBuiltinSkillLocation(location)) return "builtin" // altimate_change start — skills synced from the bound workspace live under the managed - // `_workspace` folder; they are the workspace's, not the project's. - if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" + // `_workspace` folder of this project; they are the workspace's, not the project's. Another + // project's managed folder is not this session's workspace. + if (projectRoot !== undefined) { + const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "") + if (normalized.startsWith(`${root}/.altimate-code/skill/_workspace/`)) return "workspace" + } else if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" // altimate_change end if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" @@ -260,7 +269,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change end // altimate_change start — classify origin once, reused for telemetry and the source badge - const skillOrigin = classifySkillSource(skill.location) + const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 86bba75795..f30daa4ce5 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -62,19 +62,19 @@ describe("workspace id on the telemetry context", () => { timestamp: Date.now(), }) as const - test("workspace use is counted per session and cleared on read-out", () => { + test("workspace use is counted per session and cleared explicitly", () => { Telemetry.clearWorkspaceUse("s9") - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.noteWorkspaceUse("s9", "skills") Telemetry.noteWorkspaceUse("s9", "memory", 3) Telemetry.noteWorkspaceUse("s9", "integrations") Telemetry.noteWorkspaceUse("s9", "integrations") Telemetry.noteWorkspaceUse("", "skills") // no session: ignored - Telemetry.noteWorkspaceUse("s9", "documents", 0) // nothing to count - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, documents: 0, memory: 3, integrations: 2 }) - expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.noteWorkspaceUse("s9", "skills", 0) // nothing to count + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, memory: 3, integrations: 2 }) + expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.clearWorkspaceUse("s9") - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { @@ -113,7 +113,26 @@ describe("workspace id on the telemetry context", () => { ["s3", undefined], ["s2", undefined], ]) + const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) + + // One session's turn ending (endSession, and the shutdown the loop runs after it) leaves the + // other sessions' entries alone; the ended session's later rows gain no key. + posts = [] + Telemetry.endSession("s1") + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.workspaceOf("s4")).toBe(9) + expect(Telemetry.workspaceOf("s1")).toBeUndefined() + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s4" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) + await Telemetry.flush() + const later = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") + expect(later.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ + ["s4", "9"], + ["s1", undefined], + ]) + Telemetry.endSession("s4") }) }) diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts index 3f00af02bd..547393bea3 100644 --- a/packages/opencode/test/altimate/workspace/starter.test.ts +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -118,7 +118,7 @@ describe("wrapStarter", () => { }) describe("starter_shown telemetry", () => { - test("reports what the workspace provides as counts, and unknowns as zero", () => { + test("reports what the workspace provides as counts, and leaves unknowns out", () => { const events: any[] = [] const spy = spyOn(Telemetry, "track").mockImplementation((e) => { events.push(e) @@ -129,23 +129,16 @@ describe("starter_shown telemetry", () => { expect.objectContaining({ type: "starter_shown", workspace_id: 5, - skills: Array.isArray(full.skills) ? full.skills.length : 0, - integrations: Array.isArray(full.integrations) ? full.integrations.length : 0, + skills: Array.isArray(full.skills) ? full.skills.length : undefined, + integrations: Array.isArray(full.integrations) ? full.integrations.length : undefined, knowledge: full.knowledge?.kind ?? "unknown", memory_enabled: full.memoryEnabled === true, }), ]) events.length = 0 trackStarterShown(6, { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) - expect(events[0]).toEqual( - expect.objectContaining({ - workspace_id: 6, - skills: 0, - integrations: 0, - knowledge: "unknown", - memory_enabled: false, - }), - ) + expect(events[0]).toEqual(expect.objectContaining({ workspace_id: 6, knowledge: "unknown" })) + for (const key of ["skills", "integrations", "memory_enabled"]) expect(key in events[0]).toBe(false) } finally { spy.mockRestore() } diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 1258e9b24c..45b7342126 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -10,11 +10,13 @@ describe("workspace integration tool calls", () => { spy = spyOn(Telemetry, "track").mockImplementation((e) => { rows.push(e) }) - Telemetry.clearWorkspaceUse("s1") + Telemetry.endSession("s1") + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) // s1's turn is bound }) afterEach(() => { spy.mockRestore() - Telemetry.clearWorkspaceUse("s1") + Telemetry.endSession("s1") + Telemetry.endSession("s0") }) const call = (clientName: string, outcome: "success" | "error", error?: unknown) => @@ -28,7 +30,7 @@ describe("workspace integration tool calls", () => { error, }) - test("the datamate client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { + test("the engine client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { expect(call("datamate", "success")).toBe(true) expect(rows).toHaveLength(1) expect(rows[0]).toEqual( @@ -48,24 +50,43 @@ describe("workspace integration tool calls", () => { }) test("a failed call is a tool_call error row with a masked message and no integration use", () => { - expect(call("datamates", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) + expect(call("datamate", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) expect(rows[0]).toEqual(expect.objectContaining({ status: "error", via_workspace: true, tool_type: "mcp" })) expect(rows[0].error).not.toContain("/home/someone") expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) - expect(call("datamate-acme", "error", "string failure")).toBe(true) + expect(call("datamate", "error", "string failure with token=abc123secret and https://u:pw@host/x?q=1")).toBe(true) expect(rows[1].error).toContain("string failure") + expect(rows[1].error).not.toContain("abc123secret") + expect(rows[1].error).not.toContain("u:pw@") }) - test("other MCP clients are not tracked here, and a registry tool id is not a client", () => { + test("the engine key in a session whose turn is not bound is not the workspace's", () => { + Telemetry.setContext({ sessionId: "s0", projectId: "p1" }) // unbound turn; s1 keeps its entry + expect( + trackWorkspaceToolCall({ + key: "datamate_x", + clientName: "datamate", + sessionID: "s0", + messageID: "m", + startedAt: Date.now(), + outcome: "success", + }), + ).toBe(false) + expect(rows).toHaveLength(0) + expect(call("datamate", "success")).toBe(true) // s1 is still bound + }) + + test("other MCP clients, standalone datamate servers and registry tool ids are not the workspace's engine", () => { expect(call("github", "success")).toBe(false) expect(call("filesystem", "error", new Error("x"))).toBe(false) + expect(call("datamate-acme", "success")).toBe(false) // a standalone datamate server the user configured + expect(call("datamates", "success")).toBe(false) expect(rows).toHaveLength(0) expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) // The registry tool `datamate_manager` runs through Tool.define, never through this path, and its - // id is not a datamate client name either. + // id is not the engine's client name either. expect(isWorkspaceClient("datamate_manager")).toBe(false) expect(isWorkspaceClient("datamate")).toBe(true) - expect(isWorkspaceClient("Datamates")).toBe(true) - expect(isWorkspaceClient("datamate-acme")).toBe(true) + expect(isWorkspaceClient("Datamate")).toBe(false) }) }) diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index c790e621c7..fad1643e1f 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -143,9 +143,18 @@ describe("the memory refresh tool", () => { test("a block from the bound workspace's memory is injected under the workspace scope and counted", async () => { const { Telemetry } = await import("../../src/altimate/telemetry") - listResponse = [remote("ws/one", "WORKSPACE BLOCK")] + // A project-scope block is the workspace's; a global cloud block is the account's and is + // injected too, but not counted as workspace memory. + listResponse = [ + remote("ws/one", "WORKSPACE BLOCK", { block_scope: "project", datamate_id: "7" }), + remote("acct/one", "GLOBAL BLOCK"), + ] await hydrate(SES) - expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["ws/one"]) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["acct/one", "ws/one"]) const events: any[] = [] const spy = spyOn(Telemetry, "track").mockImplementation((e) => { events.push(e) @@ -154,9 +163,11 @@ describe("the memory refresh tool", () => { Telemetry.clearWorkspaceUse(SES) const injected = await MemoryPrompt.inject(8000, { sessionID: SES }) expect(injected).toContain("WORKSPACE BLOCK") + expect(injected).toContain("GLOBAL BLOCK") const injection = events.find((e) => e.type === "memory_injection") expect(injection).toBeDefined() expect(injection.scopes_used).toContain("workspace") + expect(injection.scopes_used).toContain("global") expect(injection.workspace_blocks).toBe(1) expect(Telemetry.workspaceUseOf(SES).memory).toBe(1) } finally { diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts index 822a2930e8..2355c98c53 100644 --- a/packages/opencode/test/tool/skill-source.test.ts +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -23,6 +23,12 @@ describe("classifySkillSource", () => { expect(classifySkillSource("C:\\work\\repo\\.altimate-code\\skill\\_workspace\\x\\SKILL.md")).toBe("workspace") // a project skill that merely mentions the word is still the project's expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace-notes/SKILL.md")).toBe("project") + // With the project root known, only this project's managed folder is the workspace's. + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("workspace") + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo/")).toBe( + "workspace", + ) + expect(classifySkillSource("/work/other/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("project") }) test("a third-party skill under some other node_modules is NOT tagged Altimate", () => { From 7ad35e140a63532f003c60ccce74357398e0f833 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:37:38 +0800 Subject: [PATCH 07/19] fix: count injected workspace memory per turn; report only project write-backs Memory is injected on every step, so the count keeps the largest injection instead of summing; a global block's live mirror reports nothing; the injection row names the injecting session. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 6 ++++-- .../opencode/src/altimate/workspace/memory-sync.ts | 8 +++++--- .../src/altimate/workspace/tool-call-telemetry.ts | 10 ++++++++-- packages/opencode/src/memory/prompt.ts | 6 ++++-- .../test/altimate/telemetry/workspace-context.test.ts | 5 +++++ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 9443f5bfae..2d3181bf51 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2236,10 +2236,12 @@ export namespace Telemetry { // sessions keeps them apart; cleared when the session ends. export type WorkspaceUse = { skills: number; memory: number; integrations: number } const workspaceUse = new Map() - export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { + /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every + * step of a turn, so a sum would multiply the blocks by the step count). */ + export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1, mode: "add" | "max" = "add") { if (!sessionId || n <= 0) return const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } - use[kind] += n + use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n workspaceUse.set(sessionId, use) } export function workspaceUseOf(sessionId: string): WorkspaceUse { diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index d6735f4514..10b149f50a 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -773,7 +773,7 @@ async function push( } await MemoryApi.update(match, block.content, metadata) await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding, sessionID) + if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -808,11 +808,13 @@ async function push( log.warn("archiving an extra record produced by one create", { id: block.id, memoryId: extra }) await MemoryApi.update(extra, "", { ...metadata, archived: "true", archived_at: new Date().toISOString() }) } - if (binding) trackContributed(binding, sessionID) + if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } -// altimate_change start — a successful write-back is the workspace getting something back +// altimate_change start — a successful write-back of a project block is the workspace getting something +// back; a global block is the account's (the live mirror resolves a binding for it too, to consult the +// memory setting), so it is not reported. function trackContributed(binding: CachedBinding, sessionID?: string) { try { Telemetry.track({ diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index 77217f46a6..06cc572012 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -29,7 +29,13 @@ export function trackWorkspaceToolCall(input: { if (Telemetry.workspaceOf(input.sessionID) === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") - const message = input.error instanceof Error ? input.error.message : String(input.error) + const error = + input.error === undefined + ? undefined + : redact(Telemetry.maskString(input.error instanceof Error ? input.error.message : String(input.error))).slice( + 0, + 500, + ) Telemetry.track({ type: "tool_call", timestamp: Date.now(), @@ -43,7 +49,7 @@ export function trackWorkspaceToolCall(input: { duration_ms: Date.now() - input.startedAt, sequence_index: 0, previous_tool: null, - ...(input.error !== undefined && { error: redact(Telemetry.maskString(message)).slice(0, 500) }), + ...(error !== undefined && { error }), }) } catch { // telemetry never surfaces into the tool call diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 61590a9033..e1d4a7f289 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -303,7 +303,8 @@ export namespace MemoryPrompt { Telemetry.track({ type: "memory_injection", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the injecting session, not the process context (another turn's in serve) + session_id: ctx?.sessionID ?? Telemetry.getContext().sessionId, block_count: injectedCount, total_chars: used, budget, @@ -311,7 +312,8 @@ export namespace MemoryPrompt { // altimate_change start — workspace memory use, for memory_injection and the session's summary rows ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) - if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks) + // The turn injects on every step: the count is the most blocks any step injected, not a sum. + if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max") // altimate_change end } diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index f30daa4ce5..4386d6fc02 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -72,6 +72,11 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("", "skills") // no session: ignored Telemetry.noteWorkspaceUse("s9", "skills", 0) // nothing to count expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, memory: 3, integrations: 2 }) + // Memory is injected on every step of a turn: "max" keeps the largest injection, not a sum. + Telemetry.noteWorkspaceUse("s9", "memory", 2, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 5, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 4, "max") + expect(Telemetry.workspaceUseOf("s9").memory).toBe(5) expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.clearWorkspaceUse("s9") expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) From a80cfe4343699d5092a4e83f01beac5455a8ec1c Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:46:20 +0800 Subject: [PATCH 08/19] test: reset the telemetry context after the tool-call suite Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../test/altimate/workspace/tool-call-telemetry.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 45b7342126..80056f97db 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -17,6 +17,9 @@ describe("workspace integration tool calls", () => { spy.mockRestore() Telemetry.endSession("s1") Telemetry.endSession("s0") + // The singleton context is process-wide: left at s1/42 it would stamp a later suite's + // session-less rows. + Telemetry.setContext({ sessionId: "", projectId: "" }) }) const call = (clientName: string, outcome: "success" | "error", error?: unknown) => From bd423b4caf85b4c0cbebfe5259b3a47a171372d2 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:00:19 +0800 Subject: [PATCH 09/19] fix: track session_end before ending the turn's entries; end only the current turn The session_end row was stamped after its own workspace entry was deleted, and a cancelled turn unwinding late could end the replacement's entries. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 30 +++++++++++++------ packages/opencode/src/session/prompt.ts | 9 ++++-- .../telemetry/workspace-context.test.ts | 19 ++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2d3181bf51..21fffe2e17 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1966,6 +1966,9 @@ export namespace Telemetry { // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() const MAX_SESSION_WORKSPACES = 1024 + /** The turn generation per session: `setContext` starts one, `endSession` ends only its own, so a + * cancelled turn unwinding late cannot end the replacement's entries. */ + const sessionTurns = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2254,25 +2257,34 @@ export namespace Telemetry { export function workspaceOf(sessionId: string): number | undefined { return sessionWorkspaces.get(sessionId) } - /** A session's turn ended: its use counts are read out by then, and its workspace entry goes. */ - export function endSession(sessionId: string) { + /** A session's turn ended: its use counts are read out and its `session_end` tracked by then, and + * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's + * current one ends nothing: the replacement's entries are the replacement's. */ + export function endSession(sessionId: string, turn?: number) { + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return workspaceUse.delete(sessionId) sessionWorkspaces.delete(sessionId) + sessionTurns.delete(sessionId) } // altimate_change end - export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { + /** Returns the turn generation for the session; pass it to `endSession` so only this turn ends it. */ + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }): number { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId // altimate_change start — remembered per session for the stamping in track() - if (opts.sessionId) { - sessionWorkspaces.delete(opts.sessionId) - sessionWorkspaces.set(opts.sessionId, opts.workspaceId) - if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { - sessionWorkspaces.delete(sessionWorkspaces.keys().next().value!) - } + if (!opts.sessionId) return 0 + sessionWorkspaces.delete(opts.sessionId) + sessionWorkspaces.set(opts.sessionId, opts.workspaceId) + if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { + const oldest = sessionWorkspaces.keys().next().value! + sessionWorkspaces.delete(oldest) + sessionTurns.delete(oldest) } + const turn = (sessionTurns.get(opts.sessionId) ?? 0) + 1 + sessionTurns.set(opts.sessionId, turn) + return turn // altimate_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 61053a03fe..a4eb6b016d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -110,10 +110,11 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" -/** The session's workspace use, as session_end fields; the read ends the session's telemetry entries. */ +/** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the + * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the + * workspace. */ function workspaceUseFields(sessionID: string) { const use = Telemetry.workspaceUseOf(sessionID) - Telemetry.endSession(sessionID) return { workspace_skills_used: use.skills, workspace_memory_blocks: use.memory, @@ -745,7 +746,7 @@ export namespace SessionPrompt { // altimate_change start — the workspace this project is linked to (local binding cache, no // network) goes on the telemetry context, so every row of the turn carries workspace_id. const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) - Telemetry.setContext({ + const telemetryTurn = Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "", workspaceId: boundWorkspace?.datamateId, @@ -823,6 +824,7 @@ export namespace SessionPrompt { ...workspaceUseFields(sessionID), // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } process.once("beforeExit", emergencySessionEnd) process.once("exit", emergencySessionEnd) @@ -2224,6 +2226,7 @@ export namespace SessionPrompt { ...workspaceUseFields(sessionID), // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } await Telemetry.shutdown() // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 4386d6fc02..2eb5b871d6 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -82,6 +82,25 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) + test("a turn ends only its own entries: a cancelled turn unwinding late leaves the replacement's", () => { + const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + Telemetry.noteWorkspaceUse("s7", "skills") + const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + expect(second).toBe(first + 1) + Telemetry.noteWorkspaceUse("s7", "memory", 2) + Telemetry.endSession("s7", first) // the old generation, after the replacement started + expect(Telemetry.workspaceOf("s7")).toBe(42) + expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) + Telemetry.endSession("s7", second) + expect(Telemetry.workspaceOf("s7")).toBeUndefined() + expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + // Without a turn, the end is unconditional (tests and shutdown paths). + Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) + Telemetry.endSession("s7") + expect(Telemetry.workspaceOf("s7")).toBeUndefined() + Telemetry.setContext({ sessionId: "", projectId: "" }) + }) + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { await Telemetry.shutdown() await Telemetry.init() From 20e3f3167db9baf5853588944f526aafa8cb455b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:05:39 +0800 Subject: [PATCH 10/19] fix: issue turn tokens from one counter so an evicted session cannot reuse one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 8 +++++--- .../altimate/telemetry/workspace-context.test.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 21fffe2e17..2d5ce4f339 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1966,9 +1966,11 @@ export namespace Telemetry { // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() const MAX_SESSION_WORKSPACES = 1024 - /** The turn generation per session: `setContext` starts one, `endSession` ends only its own, so a - * cancelled turn unwinding late cannot end the replacement's entries. */ + /** The current turn token per session: `setContext` issues one, `endSession` ends only its own, so a + * cancelled turn unwinding late cannot end the replacement's entries. Tokens come from one + * process-wide counter, so an entry evicted and re-created never reuses a token a stale turn holds. */ const sessionTurns = new Map() + let nextTurn = 0 // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2282,7 +2284,7 @@ export namespace Telemetry { sessionWorkspaces.delete(oldest) sessionTurns.delete(oldest) } - const turn = (sessionTurns.get(opts.sessionId) ?? 0) + 1 + const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 2eb5b871d6..a05a90cc8f 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -86,7 +86,7 @@ describe("workspace id on the telemetry context", () => { const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) Telemetry.noteWorkspaceUse("s7", "skills") const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) - expect(second).toBe(first + 1) + expect(second).toBeGreaterThan(first) Telemetry.noteWorkspaceUse("s7", "memory", 2) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) @@ -98,6 +98,16 @@ describe("workspace id on the telemetry context", () => { Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) Telemetry.endSession("s7") expect(Telemetry.workspaceOf("s7")).toBeUndefined() + // Evicted and re-created entries never reuse a token a stale turn still holds. + const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) + for (let i = 0; i < 1100; i++) Telemetry.setContext({ sessionId: `evict-${i}`, projectId: "p1" }) + expect(Telemetry.workspaceOf("s8")).toBeUndefined() // evicted + const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) + expect(fresh).toBeGreaterThan(stale) + Telemetry.endSession("s8", stale) + expect(Telemetry.workspaceOf("s8")).toBe(6) + Telemetry.endSession("s8", fresh) + for (let i = 0; i < 1100; i++) Telemetry.endSession(`evict-${i}`) Telemetry.setContext({ sessionId: "", projectId: "" }) }) From 9336e587ae7e523d30e4a9d42d0a2bf13bce9eba Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:08:00 +0800 Subject: [PATCH 11/19] fix: name the turn's own workspace on the loop's end-of-turn rows A cancelled turn unwinding after its replacement started took the replacement's session entry; the loop now stamps the workspace it resolved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 10 ++++++++++ packages/opencode/src/session/prompt.ts | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2d5ce4f339..a1d49fcf3c 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -252,6 +252,8 @@ export namespace Telemetry { // altimate_change end | { type: "session_end" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string total_cost: number @@ -519,6 +521,8 @@ export namespace Telemetry { } | { type: "agent_outcome" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string agent: string @@ -941,6 +945,8 @@ export namespace Telemetry { // implicit quality signal for task outcome intelligence | { type: "task_outcome_signal" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** Behavioral signal derived from session outcome patterns */ @@ -1018,6 +1024,8 @@ export namespace Telemetry { // error pattern fingerprint — hashed error grouping with recovery data | { type: "error_fingerprint" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** SHA256 hash of normalized (masked) error message for grouping */ @@ -1036,6 +1044,8 @@ export namespace Telemetry { // tool chain effectiveness — aggregated tool sequence + outcome at session end | { type: "tool_chain_outcome" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** JSON-encoded ordered tool names (capped at 50) */ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a4eb6b016d..32ef721ce8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -751,6 +751,9 @@ export namespace SessionPrompt { projectId: Instance.project?.id ?? "", workspaceId: boundWorkspace?.datamateId, }) + // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn + // unwinding after its replacement started must not take the replacement's entry. + const turnWorkspace = boundWorkspace?.datamateId !== undefined ? { workspace_id: boundWorkspace.datamateId } : {} // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, @@ -814,6 +817,7 @@ export namespace SessionPrompt { emergencySessionEndFired = true Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, @@ -2126,6 +2130,7 @@ export namespace SessionPrompt { // altimate_change start — emit quality signal, tool chain, and error fingerprint events Telemetry.track({ type: "task_outcome_signal", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, signal: Telemetry.deriveQualitySignal(outcome), @@ -2140,6 +2145,7 @@ export namespace SessionPrompt { if (toolChain.length > 0) { Telemetry.track({ type: "tool_chain_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, chain: JSON.stringify(toolChain), @@ -2159,6 +2165,7 @@ export namespace SessionPrompt { for (const err of errorRecords.slice(0, 20)) { Telemetry.track({ type: "error_fingerprint", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, error_hash: err.errorHash, @@ -2191,6 +2198,7 @@ export namespace SessionPrompt { // altimate_change end Telemetry.track({ type: "agent_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, // altimate_change start — route through normalizeAgentName (shared with @@ -2216,6 +2224,7 @@ export namespace SessionPrompt { process.off("exit", emergencySessionEnd) Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, From 71ac2a9d1f05e8d8dfd6f1345e80ae9ffd63c05b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:18:26 +0800 Subject: [PATCH 12/19] fix: name the turn's workspace on workspace tool-call rows and on an unbound turn's end rows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 2 ++ .../altimate/workspace/tool-call-telemetry.ts | 12 ++++++---- packages/opencode/src/session/prompt.ts | 9 +++++++- .../workspace/tool-call-telemetry.test.ts | 22 ++++++++++++++----- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index a1d49fcf3c..e0a606bbf7 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -324,6 +324,8 @@ export namespace Telemetry { error?: string // altimate_change — the call went to the bound workspace (a datamate MCP server) via_workspace?: boolean + // altimate_change — the turn's workspace, named by the MCP path on a workspace call + workspace_id?: number } | { type: "native_call" diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index 06cc572012..b05287fa29 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -12,21 +12,24 @@ export function isWorkspaceClient(clientName: string): boolean { * Track one MCP call that went through the bound workspace's engine. MCP tools never pass through * the `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration * call becomes a `tool_call` row; a successful one also counts as an integration use for the - * session. Only the managed engine client (`datamate`) in a session whose turn is bound counts: - * a standalone `datamate-` server, or the key in an unbound session, is not the workspace's. - * Returns whether the call was tracked. + * session. Only the managed engine client (`datamate`) in a bound turn counts: a standalone + * `datamate-` server, or the key in an unbound turn, is not the workspace's. The turn's own + * workspace is passed in and named on the row, so a call that finishes after the turn was + * cancelled and replaced keeps the workspace it ran for. Returns whether the call was tracked. */ export function trackWorkspaceToolCall(input: { key: string clientName: string sessionID: string messageID: string + /** The workspace the calling turn resolved; undefined for an unbound turn. */ + workspaceId: number | undefined startedAt: number outcome: "success" | "error" error?: unknown }): boolean { if (!isWorkspaceClient(input.clientName)) return false - if (Telemetry.workspaceOf(input.sessionID) === undefined) return false + if (input.workspaceId === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") const error = @@ -42,6 +45,7 @@ export function trackWorkspaceToolCall(input: { session_id: input.sessionID, message_id: input.messageID, tool_name: input.key, + workspace_id: input.workspaceId, tool_type: "mcp", tool_category: Telemetry.categorizeToolName(input.key, "mcp"), status: input.outcome, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 32ef721ce8..e1e38d8fb6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -753,7 +753,9 @@ export namespace SessionPrompt { }) // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn // unwinding after its replacement started must not take the replacement's entry. - const turnWorkspace = boundWorkspace?.datamateId !== undefined ? { workspace_id: boundWorkspace.datamateId } : {} + // Always keyed, undefined for an unbound turn: a row that carries the key is never stamped from + // the session's entry, and the envelope drops an undefined value. + const turnWorkspace = { workspace_id: boundWorkspace?.datamateId } // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, @@ -1472,6 +1474,7 @@ export namespace SessionPrompt { messages: msgs, // altimate_change start — file rules append only to this tool result lessons, + workspaceId: boundWorkspace?.datamateId, // altimate_change end }), { step, agent: agent.name }, @@ -2337,6 +2340,8 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] // altimate_change start — optional for callers outside the main prompt loop lessons?: LessonDelivery + /** The workspace the calling turn resolved, for the rows its MCP calls emit. */ + workspaceId?: number // altimate_change end }) { using _ = log.time("resolveTools") @@ -2538,6 +2543,7 @@ export namespace SessionPrompt { clientName, sessionID: ctx.sessionID, messageID: ctx.messageID, + workspaceId: input.workspaceId, startedAt, outcome: "error", error: err, @@ -2549,6 +2555,7 @@ export namespace SessionPrompt { clientName, sessionID: ctx.sessionID, messageID: ctx.messageID, + workspaceId: input.workspaceId, startedAt, outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", }) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 80056f97db..fc8389f040 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -11,7 +11,6 @@ describe("workspace integration tool calls", () => { rows.push(e) }) Telemetry.endSession("s1") - Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) // s1's turn is bound }) afterEach(() => { spy.mockRestore() @@ -22,12 +21,18 @@ describe("workspace integration tool calls", () => { Telemetry.setContext({ sessionId: "", projectId: "" }) }) - const call = (clientName: string, outcome: "success" | "error", error?: unknown) => + const call = ( + clientName: string, + outcome: "success" | "error", + error?: unknown, + workspaceId: number | undefined = 42, + ) => trackWorkspaceToolCall({ key: `${clientName}_jira_get_issue`, clientName, sessionID: "s1", messageID: "m1", + workspaceId, startedAt: Date.now() - 5, outcome, error, @@ -43,6 +48,7 @@ describe("workspace integration tool calls", () => { tool_type: "mcp", status: "success", via_workspace: true, + workspace_id: 42, session_id: "s1", message_id: "m1", }), @@ -63,20 +69,24 @@ describe("workspace integration tool calls", () => { expect(rows[1].error).not.toContain("u:pw@") }) - test("the engine key in a session whose turn is not bound is not the workspace's", () => { - Telemetry.setContext({ sessionId: "s0", projectId: "p1" }) // unbound turn; s1 keeps its entry + test("the engine key in an unbound turn is not the workspace's; the turn's workspace, not the session's entry, names the row", () => { expect( trackWorkspaceToolCall({ key: "datamate_x", clientName: "datamate", - sessionID: "s0", + sessionID: "s1", messageID: "m", + workspaceId: undefined, startedAt: Date.now(), outcome: "success", }), ).toBe(false) expect(rows).toHaveLength(0) - expect(call("datamate", "success")).toBe(true) // s1 is still bound + // The session's current entry says 7 (a replacement turn), but this call ran for turn 42. + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 7 }) + expect(call("datamate", "success")).toBe(true) + expect(rows[0].workspace_id).toBe(42) + Telemetry.setContext({ sessionId: "", projectId: "" }) }) test("other MCP clients, standalone datamate servers and registry tool ids are not the workspace's engine", () => { From bff39312365bd90ce8082f29bc31c57f03ab5687 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:28:48 +0800 Subject: [PATCH 13/19] fix: pass the writer's session to the memory mirror; report the write-back once durable; gate counts by turn Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 11 ++++++++++- .../opencode/src/altimate/workspace/memory-sync.ts | 14 ++++++++------ .../src/altimate/workspace/tool-call-telemetry.ts | 4 +++- packages/opencode/src/memory/store.ts | 8 ++++++-- .../opencode/src/memory/tools/memory-extract.ts | 2 +- packages/opencode/src/memory/tools/memory-write.ts | 2 +- packages/opencode/src/session/prompt.ts | 6 +++++- .../altimate/telemetry/workspace-context.test.ts | 5 +++++ 8 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index e0a606bbf7..ce5bcc158f 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2255,8 +2255,17 @@ export namespace Telemetry { const workspaceUse = new Map() /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every * step of a turn, so a sum would multiply the blocks by the step count). */ - export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1, mode: "add" | "max" = "add") { + export function noteWorkspaceUse( + sessionId: string, + kind: keyof WorkspaceUse, + n = 1, + mode: "add" | "max" = "add", + /** The turn the use belongs to (from `setContext`): a use landing after its turn was replaced + * does not count toward the replacement. */ + turn?: number, + ) { if (!sessionId || n <= 0) return + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n workspaceUse.set(sessionId, use) diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 10b149f50a..838e7bbf0d 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -799,6 +799,9 @@ async function push( const [primary, ...extras] = created await MemoryApi.update(primary, block.content, metadata) await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) + // The write-back is durable here: reported now, so archiving an extra record below cannot cost + // the row (the content hash would make the next attempt "unchanged"). + if (block.scope === "project" && binding) trackContributed(binding, sessionID) // An extractor may split one submission into several records, each carrying // the metadata we sent. Only one can represent the block; the rest would @@ -808,7 +811,6 @@ async function push( log.warn("archiving an extra record produced by one create", { id: block.id, memoryId: extra }) await MemoryApi.update(extra, "", { ...metadata, archived: "true", archived_at: new Date().toISOString() }) } - if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -903,11 +905,11 @@ export async function flushPendingMirrors(timeoutMs = 30_000): Promise { /** Mirror one block. Safe to call unconditionally — returns immediately when * workspaces are disabled, the project is unbound, or the workspace has memory * disabled. */ -export async function mirrorBlock(block: MemoryBlock, directory?: string): Promise { +export async function mirrorBlock(block: MemoryBlock, directory?: string, sessionID?: string): Promise { if (!isEnabled()) return - // altimate_change — the writer's session, read before the queue: the context can be another - // session's by the time the mirror lands. - const sessionID = Telemetry.getContext().sessionId + // altimate_change — the writer's session: the one the memory tool passed, else the context's read + // before the queue (the context can be another session's by the time the mirror lands). + const writer = sessionID ?? Telemetry.getContext().sessionId // Queued BEFORE the binding lookup, not after. Both are async, so resolving // them first let two operations on one block reach `serialize` in the // opposite order to the writes that triggered them. @@ -921,7 +923,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi const binding = await currentBinding(directory) if (!binding) return if (!(await memoryEnabled(binding))) return - await push(block, binding, undefined, directory, sessionID) + await push(block, binding, undefined, directory, writer) }) return tracked(task) } diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index b05287fa29..7a6ecf66bc 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -24,6 +24,8 @@ export function trackWorkspaceToolCall(input: { messageID: string /** The workspace the calling turn resolved; undefined for an unbound turn. */ workspaceId: number | undefined + /** The calling turn (from `setContext`); a success landing after it was replaced is not counted. */ + turn?: number startedAt: number outcome: "success" | "error" error?: unknown @@ -31,7 +33,7 @@ export function trackWorkspaceToolCall(input: { if (!isWorkspaceClient(input.clientName)) return false if (input.workspaceId === undefined) return false try { - if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") + if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations", 1, "add", input.turn) const error = input.error === undefined ? undefined diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index d6b2b7f46a..11927f0d49 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -265,7 +265,11 @@ export namespace MemoryStore { }) } - export async function write(block: MemoryBlock): Promise<{ duplicates: MemoryBlock[] }> { + export async function write( + block: MemoryBlock, + // altimate_change — the writing session, for the workspace write-back's row + opts: { sessionID?: string } = {}, + ): Promise<{ duplicates: MemoryBlock[] }> { if (block.content.length > MEMORY_MAX_BLOCK_SIZE) { throw new Error( `Memory block "${block.id}" content exceeds maximum size of ${MEMORY_MAX_BLOCK_SIZE} characters (got ${block.content.length})`, @@ -327,7 +331,7 @@ export namespace MemoryStore { // local-existence check would then look for this block in the wrong tree // and skip it as deleted. const owningDirectory = safeDirectory() - void mirrorBlock(block, owningDirectory).catch((e) => { + void mirrorBlock(block, owningDirectory, opts.sessionID).catch((e) => { mirrorLog.warn("failed to mirror memory block to workspace", { id: block.id, scope: block.scope, diff --git a/packages/opencode/src/memory/tools/memory-extract.ts b/packages/opencode/src/memory/tools/memory-extract.ts index 1a316117bb..46fdb9b34b 100644 --- a/packages/opencode/src/memory/tools/memory-extract.ts +++ b/packages/opencode/src/memory/tools/memory-extract.ts @@ -50,7 +50,7 @@ export const MemoryExtractTool = Tool.define("altimate_memory_extract", { updated: now, citations: fact.citations, content: fact.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" results.push(` ✓ ${action} "${fact.id}" (${fact.scope})`) diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 4613d9756d..439bc9ec0b 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -51,7 +51,7 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { expires: args.expires, citations: args.citations, content: args.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" let output = `${action} memory block "${args.id}" in ${args.scope} scope.` diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e1e38d8fb6..5b7ef89c19 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1475,6 +1475,7 @@ export namespace SessionPrompt { // altimate_change start — file rules append only to this tool result lessons, workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, // altimate_change end }), { step, agent: agent.name }, @@ -2340,8 +2341,10 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] // altimate_change start — optional for callers outside the main prompt loop lessons?: LessonDelivery - /** The workspace the calling turn resolved, for the rows its MCP calls emit. */ + /** The workspace the calling turn resolved, for the rows its MCP calls emit, and the turn itself, + * so a call landing after the turn was replaced does not count toward the replacement. */ workspaceId?: number + turn?: number // altimate_change end }) { using _ = log.time("resolveTools") @@ -2556,6 +2559,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, messageID: ctx.messageID, workspaceId: input.workspaceId, + turn: input.turn, startedAt, outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", }) diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index a05a90cc8f..383d660146 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -91,6 +91,11 @@ describe("workspace id on the telemetry context", () => { Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) + // A use landing with a stale turn does not count toward the replacement. + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", first) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(0) + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", second) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) expect(Telemetry.workspaceOf("s7")).toBeUndefined() expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) From bd1190b8ff3a5a12ef4644790ba11e7b235255fc Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:35:41 +0800 Subject: [PATCH 14/19] fix: name the writer's session on the memory_operation row too Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/memory/store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index 11927f0d49..2665e033be 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -312,7 +312,8 @@ export namespace MemoryStore { Telemetry.track({ type: "memory_operation", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the writing session when the caller passed it, else the context's + session_id: opts.sessionID ?? Telemetry.getContext().sessionId, operation: "write", scope: block.scope, block_id: block.id, From 6235bc5b8bcf41ffbcae4e37c4c1bf8d45c0b7d5 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:39:49 +0800 Subject: [PATCH 15/19] fix: key the workspace-use counters by turn Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 25 +++++++++++++------ packages/opencode/src/session/prompt.ts | 10 ++++---- .../telemetry/workspace-context.test.ts | 4 +++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index ce5bcc158f..895b4b7814 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2252,7 +2252,10 @@ export namespace Telemetry { // whether any workspace context was in play. Keyed by session so a serve process with several // sessions keeps them apart; cleared when the session ends. export type WorkspaceUse = { skills: number; memory: number; integrations: number } + /** Keyed by session and turn, so a cancelled turn unwinding late and its replacement never share + * counts; a site that carries no turn counts toward the session's current one. */ const workspaceUse = new Map() + const useKey = (sessionId: string, turn?: number) => `${sessionId}#${turn ?? sessionTurns.get(sessionId) ?? 0}` /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every * step of a turn, so a sum would multiply the blocks by the step count). */ export function noteWorkspaceUse( @@ -2266,15 +2269,17 @@ export namespace Telemetry { ) { if (!sessionId || n <= 0) return if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return - const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } + const key = useKey(sessionId, turn) + const use = workspaceUse.get(key) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n - workspaceUse.set(sessionId, use) + workspaceUse.set(key, use) } - export function workspaceUseOf(sessionId: string): WorkspaceUse { - return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + /** The counts of a turn (the session's current one when no turn is given). */ + export function workspaceUseOf(sessionId: string, turn?: number): WorkspaceUse { + return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(useKey(sessionId, turn)) } } - export function clearWorkspaceUse(sessionId: string) { - workspaceUse.delete(sessionId) + export function clearWorkspaceUse(sessionId: string, turn?: number) { + workspaceUse.delete(useKey(sessionId, turn)) } /** The workspace a session's turn bound, or undefined when unbound or unknown. */ export function workspaceOf(sessionId: string): number | undefined { @@ -2284,8 +2289,12 @@ export namespace Telemetry { * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's * current one ends nothing: the replacement's entries are the replacement's. */ export function endSession(sessionId: string, turn?: number) { - if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return - workspaceUse.delete(sessionId) + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) { + // A turn that was replaced ends only its own counts; the replacement's entries stay. + workspaceUse.delete(useKey(sessionId, turn)) + return + } + workspaceUse.delete(useKey(sessionId, turn)) sessionWorkspaces.delete(sessionId) sessionTurns.delete(sessionId) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5b7ef89c19..87ddee367b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -113,8 +113,8 @@ import { readLocalBinding } from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the * workspace. */ -function workspaceUseFields(sessionID: string) { - const use = Telemetry.workspaceUseOf(sessionID) +function workspaceUseFields(sessionID: string, turn: number) { + const use = Telemetry.workspaceUseOf(sessionID, turn) return { workspace_skills_used: use.skills, workspace_memory_blocks: use.memory, @@ -827,7 +827,7 @@ export namespace SessionPrompt { tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, // altimate_change start — what of the bound workspace this session used - ...workspaceUseFields(sessionID), + ...workspaceUseFields(sessionID, telemetryTurn), // altimate_change end }) Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only @@ -2143,7 +2143,7 @@ export namespace SessionPrompt { duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", // altimate_change — whether workspace skills, memory or integrations were in play - workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), + workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID, telemetryTurn)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome if (toolChain.length > 0) { @@ -2236,7 +2236,7 @@ export namespace SessionPrompt { tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, // altimate_change start — what of the bound workspace this session used - ...workspaceUseFields(sessionID), + ...workspaceUseFields(sessionID, telemetryTurn), // altimate_change end }) Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 383d660146..e3ab704b60 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -88,8 +88,12 @@ describe("workspace id on the telemetry context", () => { const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) expect(second).toBeGreaterThan(first) Telemetry.noteWorkspaceUse("s7", "memory", 2) + // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) // A use landing with a stale turn does not count toward the replacement. Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", first) From be3b51691a4ebc9defdcfca70b270c72904f2257 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:58:13 +0800 Subject: [PATCH 16/19] fix: name the workspace explicitly on every row; no ambient stamping `track()` no longer stamps `workspace_id` from a process-wide or per-session fallback (five rounds of cancelled-turn attribution bugs came from it). `setContext` returns a turn token; loop, tool, skill, memory and first-job rows carry their turn's workspace explicitly; counters key on the turn. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/plugin/onboarding-telemetry.ts | 8 +- .../opencode/src/altimate/telemetry/index.ts | 86 +++++++++---------- .../opencode/src/altimate/tool-zod-compat.ts | 8 ++ packages/opencode/src/memory/prompt.ts | 5 +- packages/opencode/src/memory/types.ts | 4 + packages/opencode/src/session/prompt.ts | 15 +++- packages/opencode/src/tool/skill.ts | 3 +- packages/opencode/src/tool/tool.ts | 9 ++ .../telemetry/workspace-context.test.ts | 63 +++----------- 9 files changed, 104 insertions(+), 97 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 4fed174ee1..3a1cad1d90 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,6 +12,7 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" +import { Telemetry } from "@/altimate/telemetry" // altimate_change start — AI-8398 workspaces trigger. Reaches into the same // EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute // so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) @@ -261,7 +262,12 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() - const MAX_SESSION_WORKSPACES = 1024 - /** The current turn token per session: `setContext` issues one, `endSession` ends only its own, so a - * cancelled turn unwinding late cannot end the replacement's entries. Tokens come from one - * process-wide counter, so an entry evicted and re-created never reuses a token a stale turn holds. */ + // altimate_change start — the current turn token per session: `setContext` issues one, `endSession` + // ends only its own, so a cancelled turn unwinding late cannot end the replacement's counters. + // Tokens come from one process-wide counter, so a token is never reused. The map holds sessions + // mid-turn; an entry goes at the turn's end. const sessionTurns = new Map() let nextTurn = 0 + /** The workspace of each session's current turn, for an emitter that runs inside the turn but + * outside the loop (a plugin hook) and asks for it explicitly; nothing is stamped from it. */ + const turnWorkspaces = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2281,22 +2296,19 @@ export namespace Telemetry { export function clearWorkspaceUse(sessionId: string, turn?: number) { workspaceUse.delete(useKey(sessionId, turn)) } - /** The workspace a session's turn bound, or undefined when unbound or unknown. */ - export function workspaceOf(sessionId: string): number | undefined { - return sessionWorkspaces.get(sessionId) - } - /** A session's turn ended: its use counts are read out and its `session_end` tracked by then, and - * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's - * current one ends nothing: the replacement's entries are the replacement's. */ + /** A session's turn ended: its use counts are read out and its `session_end` tracked by then. With + * `turn` (from `setContext`), a turn that is no longer the session's current one ends only its own + * counts: the replacement's are the replacement's. */ export function endSession(sessionId: string, turn?: number) { - if (turn !== undefined && sessionTurns.get(sessionId) !== turn) { - // A turn that was replaced ends only its own counts; the replacement's entries stay. - workspaceUse.delete(useKey(sessionId, turn)) - return - } workspaceUse.delete(useKey(sessionId, turn)) - sessionWorkspaces.delete(sessionId) + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return sessionTurns.delete(sessionId) + turnWorkspaces.delete(sessionId) + } + /** The workspace of the session's current turn, for an emitter inside that turn; undefined when + * unbound or when no turn is running. An explicit lookup, never a stamp. */ + export function turnWorkspaceOf(sessionId: string): number | undefined { + return turnWorkspaces.get(sessionId) } // altimate_change end @@ -2305,15 +2317,9 @@ export namespace Telemetry { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId - // altimate_change start — remembered per session for the stamping in track() + // altimate_change start — a turn token for the session's counters and its end if (!opts.sessionId) return 0 - sessionWorkspaces.delete(opts.sessionId) - sessionWorkspaces.set(opts.sessionId, opts.workspaceId) - if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { - const oldest = sessionWorkspaces.keys().next().value! - sessionWorkspaces.delete(oldest) - sessionTurns.delete(oldest) - } + turnWorkspaces.set(opts.sessionId, opts.workspaceId) const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn @@ -2471,16 +2477,10 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end - // altimate_change start — the bound workspace is read now, not at flush, and for the event's own - // session: a serve process runs sessions side by side, so the process-wide context can belong to - // another session's turn. A row with no session takes the current context; a row naming a session - // that never set the context gains no key rather than another session's. - if (!("workspace_id" in event)) { - const own = - typeof eventSession === "string" && eventSession !== "" ? sessionWorkspaces.get(eventSession) : workspaceId - if (own !== undefined) (event as any).workspace_id = own - } - // altimate_change end + // altimate_change — no ambient workspace stamping: a row names its workspace itself (the prompt + // loop's rows, tool rows and memory rows carry the turn's; workspace rows carry their own) or it + // carries none. A process-wide or per-session fallback kept attributing cancelled turns' rows to + // their replacements. buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { buffer.shift() diff --git a/packages/opencode/src/altimate/tool-zod-compat.ts b/packages/opencode/src/altimate/tool-zod-compat.ts index 76e1bff413..a0a44ae6b7 100644 --- a/packages/opencode/src/altimate/tool-zod-compat.ts +++ b/packages/opencode/src/altimate/tool-zod-compat.ts @@ -28,6 +28,10 @@ export type Metadata = { [key: string]: any; error?: string } export interface LegacyContext { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, as on Tool.Context + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -221,6 +225,10 @@ function legacyDefToDef(legacy: LegacyToolDef): DefWithoutID { const legacyCtx: LegacyContext = { sessionID: ctx.sessionID, messageID: ctx.messageID, + // altimate_change start — carried through for the rows a legacy tool emits + workspaceId: ctx.workspaceId, + turn: ctx.turn, + // altimate_change end agent: ctx.agent, abort: ctx.abort, callID: ctx.callID, diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index e1d4a7f289..7cc30c852b 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -305,6 +305,7 @@ export namespace MemoryPrompt { timestamp: Date.now(), // altimate_change — the injecting session, not the process context (another turn's in serve) session_id: ctx?.sessionID ?? Telemetry.getContext().sessionId, + workspace_id: ctx?.workspaceId, // altimate_change — the turn's workspace, named explicitly block_count: injectedCount, total_chars: used, budget, @@ -313,7 +314,9 @@ export namespace MemoryPrompt { ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) // The turn injects on every step: the count is the most blocks any step injected, not a sum. - if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max") + if (workspaceBlocks > 0 && ctx?.sessionID) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max", ctx.turn) + } // altimate_change end } diff --git a/packages/opencode/src/memory/types.ts b/packages/opencode/src/memory/types.ts index 8f3e34880c..61db9077ea 100644 --- a/packages/opencode/src/memory/types.ts +++ b/packages/opencode/src/memory/types.ts @@ -46,6 +46,10 @@ export const AGENT_TRAINING_RELEVANCE: Record { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows - if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") + if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) // altimate_change end // altimate_change end @@ -281,6 +281,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { type: "skill_used", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, skill_name: skill.name, skill_source: skillOrigin, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c4bd52eca1..8758656029 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -55,6 +55,10 @@ export class InvalidArgumentsError extends Schema.TaggedErrorClass = { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, named on the rows a tool emits + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -139,6 +143,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -154,6 +159,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -184,6 +190,7 @@ function altimateTrackSuccess( type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -221,6 +228,7 @@ function altimateTrackSuccess( type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -238,6 +246,7 @@ function altimateTrackSuccess( type: "sql_quality", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: toolCategory, finding_count: findings.length, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index e3ab704b60..0126cdd060 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -82,7 +82,7 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) - test("a turn ends only its own entries: a cancelled turn unwinding late leaves the replacement's", () => { + test("a turn ends only its own counters; a stale turn's use does not count", () => { const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) Telemetry.noteWorkspaceUse("s7", "skills") const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) @@ -90,9 +90,9 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "memory", 2) // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) + expect(Telemetry.turnWorkspaceOf("s7")).toBe(42) // an explicit lookup for in-turn emitters expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started - expect(Telemetry.workspaceOf("s7")).toBe(42) expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) // A use landing with a stale turn does not count toward the replacement. @@ -101,32 +101,27 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", second) expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) - expect(Telemetry.workspaceOf("s7")).toBeUndefined() expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) - // Without a turn, the end is unconditional (tests and shutdown paths). - Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) - Telemetry.endSession("s7") - expect(Telemetry.workspaceOf("s7")).toBeUndefined() - // Evicted and re-created entries never reuse a token a stale turn still holds. + expect(Telemetry.turnWorkspaceOf("s7")).toBeUndefined() + // Tokens never repeat, so a stale end can never hit a later turn. const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) - for (let i = 0; i < 1100; i++) Telemetry.setContext({ sessionId: `evict-${i}`, projectId: "p1" }) - expect(Telemetry.workspaceOf("s8")).toBeUndefined() // evicted + Telemetry.endSession("s8", stale) const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) expect(fresh).toBeGreaterThan(stale) + Telemetry.noteWorkspaceUse("s8", "skills") Telemetry.endSession("s8", stale) - expect(Telemetry.workspaceOf("s8")).toBe(6) + expect(Telemetry.workspaceUseOf("s8", fresh).skills).toBe(1) Telemetry.endSession("s8", fresh) - for (let i = 0; i < 1100; i++) Telemetry.endSession(`evict-${i}`) Telemetry.setContext({ sessionId: "", projectId: "" }) }) - test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { + test("a row carries the workspace it names and nothing is stamped from the context", async () => { await Telemetry.shutdown() await Telemetry.init() Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) expect(Telemetry.getContext().workspaceId).toBe(42) - Telemetry.track(startup("bound")) + Telemetry.track(startup("bound")) // names none: carries none, whatever the context holds Telemetry.track({ type: "workspace_linked", timestamp: Date.now(), @@ -134,48 +129,16 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) - // Another session's turn moves the context (a serve process) before the batch is serialised, and - // before s1 tracks its next rows: every row still carries the workspace of its own session. - Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) - Telemetry.track(startup("unbound")) Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s3" }) // never set the context - Telemetry.setContext({ sessionId: "s4", projectId: "p1", workspaceId: 9 }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1", workspace_id: undefined }) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") - expect(bound.data.baseData.properties.workspace_id).toBe("42") + expect("workspace_id" in bound.data.baseData.properties).toBe(false) expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") - expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + expect(linked.data.baseData.properties.workspace_id).toBe("7") const unlinked = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") - expect(unlinked.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ - ["s1", "42"], - ["s2", undefined], - ["s3", undefined], - ["s2", undefined], - ]) - - const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") - expect("workspace_id" in unbound.data.baseData.properties).toBe(false) - - // One session's turn ending (endSession, and the shutdown the loop runs after it) leaves the - // other sessions' entries alone; the ended session's later rows gain no key. - posts = [] + expect(unlinked.map((e) => e.data.baseData.properties.workspace_id)).toEqual([undefined, undefined]) Telemetry.endSession("s1") - await Telemetry.shutdown() - await Telemetry.init() - expect(Telemetry.workspaceOf("s4")).toBe(9) - expect(Telemetry.workspaceOf("s1")).toBeUndefined() - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s4" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) - await Telemetry.flush() - const later = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") - expect(later.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ - ["s4", "9"], - ["s1", undefined], - ]) - Telemetry.endSession("s4") }) }) From 3ce788b42ae507cbc839fc1378f11d9d1ba7d25d Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 00:11:53 +0800 Subject: [PATCH 17/19] fix: the tool hook input carries the turn's workspace; batch and warehouse rows name it `tool.execute.after` gains `workspaceId`, set by the three trigger sites from the turn that ran the tool, so `first_job_completed` names it without asking the session for its current turn. `batch` passes `workspaceId`/`turn` to its inner contexts; `warehouse_add`'s suggestion failure names the turn's workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/plugin/onboarding-telemetry.ts | 8 ++--- .../opencode/src/altimate/telemetry/index.ts | 10 ------- .../src/altimate/tools/warehouse-add.ts | 1 + packages/opencode/src/session/prompt.ts | 3 ++ packages/opencode/src/tool/batch.ts | 3 ++ .../altimate/telemetry/onboarding.test.ts | 19 ++++++++++++ .../telemetry/workspace-context.test.ts | 2 -- packages/opencode/test/tool/batch.test.ts | 30 +++++++++++++++++++ packages/plugin/src/index.ts | 6 +++- 9 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 3a1cad1d90..b249f7fe79 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,7 +12,6 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" -import { Telemetry } from "@/altimate/telemetry" // altimate_change start — AI-8398 workspaces trigger. Reaches into the same // EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute // so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) @@ -262,10 +261,11 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() let nextTurn = 0 - /** The workspace of each session's current turn, for an emitter that runs inside the turn but - * outside the loop (a plugin hook) and asks for it explicitly; nothing is stamped from it. */ - const turnWorkspaces = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2303,12 +2300,6 @@ export namespace Telemetry { workspaceUse.delete(useKey(sessionId, turn)) if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return sessionTurns.delete(sessionId) - turnWorkspaces.delete(sessionId) - } - /** The workspace of the session's current turn, for an emitter inside that turn; undefined when - * unbound or when no turn is running. An explicit lookup, never a stamp. */ - export function turnWorkspaceOf(sessionId: string): number | undefined { - return turnWorkspaces.get(sessionId) } // altimate_change end @@ -2319,7 +2310,6 @@ export namespace Telemetry { workspaceId = opts.workspaceId // altimate_change start — a turn token for the session's counters and its end if (!opts.sessionId) return 0 - turnWorkspaces.set(opts.sessionId, opts.workspaceId) const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 54adc86657..185f9f3e6b 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -121,6 +121,7 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva type: "core_failure", timestamp: Date.now(), session_id: Telemetry.getContext().sessionId || "unknown-session", + workspace_id: ctx.workspaceId, tool_name: "warehouse_add", tool_category: "warehouse", error_class: "internal", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9c0f456880..f6b46b0ace 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1085,6 +1085,7 @@ export namespace SessionPrompt { sessionID, callID: part.id, args: taskArgs, + workspaceId: boundWorkspace?.datamateId, // altimate_change — the turn's workspace, for plugin rows }, result, ) @@ -2483,6 +2484,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: ctx.callID, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, // altimate_change start — plugins observe the source-stamped output stamped, @@ -2585,6 +2587,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: opts.toolCallId, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, result, ) diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index c1eeb0adbc..720eb92413 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -23,6 +23,9 @@ function toEffectContext(ctx: LegacyContext, callID: string): Tool.Context { agent: ctx.agent, abort: ctx.abort, callID, + // the turn's workspace and token, so the inner tools' rows name it + workspaceId: ctx.workspaceId, + turn: ctx.turn, extra: ctx.extra, messages: ctx.messages, metadata: (input) => Effect.sync(() => ctx.metadata(input)), diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index fd252a6c02..266a394eac 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -215,6 +215,25 @@ describe("activation events", () => { expect((events.find((e) => e.type === "first_job_completed") as any).job).toBe("sample_duck_db") }) + test("the first job's row names the workspace the hook input carries", async () => { + const events = captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + + // The trigger site sets `workspaceId` from the turn that ran the tool; the hook never asks the + // session for its current turn (a replacement's, when a cancelled turn's tool returns late). + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3w", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + const completed = events.find((e) => e.type === "first_job_completed") as any + expect(completed.workspace_id).toBe(7) + expect("workspace_id" in (events.find((e) => e.type === "activation_job_selected") as any)).toBe(false) + }) + test("a failed sample setup counts as selected but not completed", async () => { const events = captureEvents() const hooks = await plugin() diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 0126cdd060..758c5b4474 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -90,7 +90,6 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "memory", 2) // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) - expect(Telemetry.turnWorkspaceOf("s7")).toBe(42) // an explicit lookup for in-turn emitters expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) @@ -102,7 +101,6 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) - expect(Telemetry.turnWorkspaceOf("s7")).toBeUndefined() // Tokens never repeat, so a stale end can never hit a later turn. const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) Telemetry.endSession("s8", stale) diff --git a/packages/opencode/test/tool/batch.test.ts b/packages/opencode/test/tool/batch.test.ts index 556a9e9c33..a6a1288801 100644 --- a/packages/opencode/test/tool/batch.test.ts +++ b/packages/opencode/test/tool/batch.test.ts @@ -66,6 +66,36 @@ describe("BatchTool: file lessons", () => { }) }) +describe("BatchTool: inner tool context", () => { + test("carries the turn's workspace and token to the inner tools", async () => { + const tool = await getToolInfo() + let seen: { workspaceId?: number; turn?: number } | undefined + const registry = spyOn(ToolRegistry, "tools").mockResolvedValue([{ + id: "probe", + registrySource: undefined, + description: "captures its context", + parameters: Schema.Unknown, + execute: (_args: unknown, ctx: { workspaceId?: number; turn?: number }) => { + seen = { workspaceId: ctx.workspaceId, turn: ctx.turn } + return Effect.succeed({ title: "ok", output: "ok", metadata: {} }) + }, + }] as any) + const updatePart = Object.assign(async (part: Parameters[0]) => part, Session.updatePart) + const updates = spyOn(Session, "updatePart").mockImplementation(updatePart) + try { + const result = await tool.execute({ tool_calls: [{ tool: "probe", parameters: {} }] }, { + sessionID: SessionID.make("ses_batch_ctx"), messageID: MessageID.make("msg_batch_ctx"), agent: "build", + abort: AbortSignal.any([]), messages: [], extra: {}, workspaceId: 7, turn: 3, + } as any) + expect(result.metadata.successful).toBe(1) + expect(seen).toEqual({ workspaceId: 7, turn: 3 }) + } finally { + updates.mockRestore() + registry.mockRestore() + } + }) +}) + describe("BatchTool: schema validation", () => { test("rejects empty tool_calls array", async () => { const tool = await getToolInfo() diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139df..42d3e1fdb8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -271,14 +271,18 @@ export interface Hooks { input: { cwd: string; sessionID?: string; callID?: string }, output: { env: Record }, ) => Promise + // altimate_change start — `workspaceId`: the workspace of the turn the tool ran in, set by the + // trigger site, so a plugin's own rows name it without consulting the session's current turn + // (which may be a replacement by the time a cancelled turn's tool returns) "tool.execute.after"?: ( - input: { tool: string; sessionID: string; callID: string; args: any }, + input: { tool: string; sessionID: string; callID: string; args: any; workspaceId?: number }, output: { title: string output: string metadata: any }, ) => Promise + // altimate_change end "experimental.chat.messages.transform"?: ( input: {}, output: { From cf996bf4c66b995faada0069827b0f714c36afd8 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 01:54:34 +0800 Subject: [PATCH 18/19] fix: a pending task's context carries the turn's workspace; the warehouse failure row names the tool's session Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/tools/warehouse-add.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 185f9f3e6b..fb4803240a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -120,7 +120,7 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva Telemetry.track({ type: "core_failure", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId || "unknown-session", + session_id: ctx.sessionID, workspace_id: ctx.workspaceId, tool_name: "warehouse_add", tool_category: "warehouse", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f6b46b0ace..b2242a3930 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1039,6 +1039,10 @@ export namespace SessionPrompt { sessionID: sessionID, abort, callID: part.callID, + // altimate_change start — the turn's workspace and token, for the task's own rows + workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, + // altimate_change end extra: { bypassAgentCheck: true }, // altimate_change start — fork MessageV2.WithParts ≡ core SessionV1.WithParts at the Tool.Context boundary messages: msgs as unknown as Tool.Context["messages"], From 083918d2682c6a719d5c6f83683df3e4efb606ea Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 02:17:44 +0800 Subject: [PATCH 19/19] fix: count workspace skills only in a bound turn; attachment reads carry the binding Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/session/prompt.ts | 2 ++ packages/opencode/src/tool/skill.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b2242a3930..1fef0bfbc7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2909,6 +2909,8 @@ export namespace SessionPrompt { const model = await Provider.getModel(info.model.providerID, info.model.modelID) const readCtx: Tool.Context = { sessionID: input.sessionID, + // altimate_change — the project's binding, so the attachment read's tool rows name the workspace + workspaceId: (await readLocalBinding(Instance.directory).catch(() => null))?.datamateId, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 44b10d4beb..26271bb804 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -271,7 +271,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows - if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) + // Only a bound turn's use counts: a stale `_workspace` folder in an unbound project is not the workspace's. + if (skillOrigin === "workspace" && ctx.workspaceId !== undefined) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) + } // altimate_change end // altimate_change end