From bc9e1c5ccf03d46d93cc721850a1e2afe6ec8c74 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 11 Nov 2026 17:32:57 +0800 Subject: [PATCH 1/4] fix: a failed prompt leaves no workspace verdict, and the review's test gaps - Every attachment part settles before a failed one is rethrown, so a late part can't put back the cleared verdict. Tested. - The first-events loop test runs with the workspace feature on. - The telemetry event list has both first-event types. - A comment no longer names a branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- .../opencode/src/altimate/workspace/state.ts | 3 +- packages/opencode/src/session/prompt.ts | 16 ++++- .../session/pinned-workspace-loop.test.ts | 65 ++++++++++++++++++- .../workspace-first-events-loop.test.ts | 19 +++++- .../opencode/test/telemetry/telemetry.test.ts | 4 +- 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 24f62d57a6..959d859d44 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -793,7 +793,8 @@ export async function resolveTurnBinding( waitMs = TURN_PIN_WAIT_MS, ): Promise { // With the workspace kill switch on, nothing else in the session reads the pin, so the turn sends - // no validation request and waits for none. Its rows read the link, as they do on `main`. + // no validation request and waits for none. Its rows read the link, as they did before the pin + // was honoured. if (!workspaceEnabled()) return linkedTurnBinding(directory) let timer: ReturnType | undefined const deadline = new Promise((done) => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 03558326bf..fe4cad3884 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3195,6 +3195,14 @@ export namespace SessionPrompt { ? await unvalidatedTurnBinding(Instance.directory) : await turnBinding(input.sessionID, Instance.directory) )?.datamateId + // Every part settles before a failed one is rethrown. A part still running after another failed could + // otherwise read the turn's workspace once `prompt` has cleared it, and leave a verdict for the next + // loop started without a prompt (summarize, resume) to take. + function settled(results: PromiseSettledResult[]): T[] { + const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (failed) throw failed.reason + return results.map((result) => (result as PromiseFulfilledResult).value) + } // altimate_change end const agentName = input.agent ?? (await Agent.defaultAgent()) const agent = await Agent.get(agentName) @@ -3250,7 +3258,9 @@ export namespace SessionPrompt { id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const parts = await Promise.all( + // altimate_change start — every part settles before a failure is rethrown (see `settled`) + const parts = await Promise.allSettled( + // altimate_change end input.parts.map(async (part): Promise[]> => { if (part.type === "file") { // before checking the protocol we check if this is an mcp resource because it needs special handling @@ -3580,7 +3590,9 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat().map(assign)) + // altimate_change start — every part settles before a failure is rethrown (see `settled`) + ).then((x) => settled(x).flat().map(assign)) + // altimate_change end await Plugin.trigger( "chat.message", diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index 4ae7254553..cc5a709f3e 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -27,9 +27,10 @@ import { readLocalBinding, recordApprovedBinding, TURN_PIN_WAIT_MS, + turnBinding, } from "../../src/altimate/workspace/state" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { TestLLMServer } from "../lib/llm-server" import { provideTmpdirServerLegacy } from "./legacy-instance" import { LSP } from "@/lsp/lsp" @@ -64,6 +65,9 @@ const mcp = Layer.succeed( }), ) +/** The ranged-file attachment's symbol lookup. Instant unless a test slows it down. */ +let symbolLookup: () => Promise = async () => [] + const lsp = Layer.succeed( LSP.Service, LSP.Service.of({ @@ -76,7 +80,7 @@ const lsp = Layer.succeed( definition: () => Effect.succeed([]), references: () => Effect.succeed([]), implementation: () => Effect.succeed([]), - documentSymbol: () => Effect.succeed([]), + documentSymbol: () => Effect.promise(() => symbolLookup()), workspaceSymbol: () => Effect.succeed([]), prepareCallHierarchy: () => Effect.succeed([]), incomingCalls: () => Effect.succeed([]), @@ -154,6 +158,7 @@ const originalKillSwitch = process.env.ALTIMATE_DISABLE_WORKSPACE beforeEach(() => { rows = [] + symbolLookup = async () => [] delete process.env.ALTIMATE_DISABLE_WORKSPACE // Cleared, so a pin in the runner's own environment can't decide the unpinned case; restored after. inheritedPin = Object.fromEntries(PIN_VARS.map((name) => [name, process.env[name]])) @@ -358,6 +363,62 @@ it.live("a directory attachment's read names the turn's workspace, as a file's d { timeout: 60_000 }, ) +it.live("a prompt that fails leaves no verdict, even from an attachment read that ends after the failure", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir }) { + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) + // The ranged file's symbol lookup outlasts the missing directory, whose failed read fails the prompt. + symbolLookup = () => new Promise((done) => setTimeout(() => done([]), 300)) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "a session", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const exit = yield* Effect.exit( + prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [ + { type: "text", text: "read these" }, + { + type: "file", + url: pathToFileURL(path.join(dir, "missing-dir")).href, + filename: "missing-dir", + mime: "application/x-directory", + }, + { + type: "file", + url: `${pathToFileURL(path.join(dir, "missing.txt")).href}?start=3&end=3`, + filename: "missing.txt", + mime: "text/plain", + }, + ], + }), + ) + expect(exit._tag).toBe("Failure") + // Both attachments' reads have run: the ranged file's row is the last thing its part does. + yield* pollWithTimeout( + Effect.sync(() => (reads().length === 2 ? true : undefined)), + "the ranged file's read never ran", + ) + yield* Effect.promise(() => + recordApprovedBinding( + dir, + { datamateId: RELINKED, datamateName: "relinked", linkedAt: Date.now(), repoRemote: null, projectPath: dir } as never, + { awaitBackfill: true }, + ), + ) + + // A loop started now without a prompt (summarize, resume) resolves its own verdict. + expect((yield* Effect.promise(() => turnBinding(session.id, dir)))?.datamateId).toBe(RELINKED) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + it.live("a noReply prompt's attachments in a pinned session name no workspace and send no validation", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir }) { diff --git a/packages/opencode/test/session/workspace-first-events-loop.test.ts b/packages/opencode/test/session/workspace-first-events-loop.test.ts index 6a7a8294c9..04831be65b 100644 --- a/packages/opencode/test/session/workspace-first-events-loop.test.ts +++ b/packages/opencode/test/session/workspace-first-events-loop.test.ts @@ -17,7 +17,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { Telemetry } from "../../src/altimate/telemetry" import * as OnboardingTelemetry from "../../src/altimate/telemetry/onboarding" import { AltimateApi } from "../../src/altimate/api/client" -import { readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" +import { __resetPinValidation, readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" import * as WorkspaceFirstEvents from "../../src/altimate/workspace/first-events" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -139,9 +139,22 @@ let rows: Row[] = [] let restore: (() => void)[] = [] let stateHome = "" const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME +// The suite's preload turns the workspace feature off, and with it off a turn never reads the pin. These +// tests turn it on, as a user's CLI has it, and clear any pin in the runner's own environment. +const PIN_VARS = [ + "ALTIMATE_CODE_SERVE", + "ALTIMATE_PINNED_WORKSPACE_ID", + "ALTIMATE_PINNED_WORKSPACE_NAME", + "ALTIMATE_PINNED_WORKSPACE_ROOT", +] +const inherited = Object.fromEntries( + ["ALTIMATE_DISABLE_WORKSPACE", ...PIN_VARS].map((name) => [name, process.env[name]]), +) beforeEach(() => { rows = [] + for (const name of Object.keys(inherited)) delete process.env[name] + __resetPinValidation() stateHome = mkdtempSync(path.join(os.tmpdir(), "first-events-loop-state-")) process.env.OPENCODE_TEST_STATE_HOME = stateHome WorkspaceFirstEvents.resetForTest() @@ -164,6 +177,10 @@ beforeEach(() => { afterEach(() => { for (const undo of restore) undo() OnboardingTelemetry.resetForTest() + for (const [name, value] of Object.entries(inherited)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome rmSync(stateHome, { recursive: true, force: true }) diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index 593759d34a..d1e07f6ddb 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -258,11 +258,13 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ "rules_delivered", "rules_learning_changed", "rules_command", + "first_prompt_sent", + "first_job_completed", ] describe("telemetry.event-types", () => { test("all event types are valid", () => { - expect(ALL_EVENT_TYPES.length).toBe(55) + expect(ALL_EVENT_TYPES.length).toBe(57) }) }) From 4d31646cde34a8fee37502eb349f99eca55e0983 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 11 Nov 2026 17:37:15 +0800 Subject: [PATCH 2/4] docs: with the workspace kill switch set, rows still name an earlier link States what the CLI already does: a cached link still names the turn's rows and still sends the workspace's first events once. No code change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- docs/docs/reference/telemetry.md | 1 + packages/core/src/flag/flag.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index a421757654..f2bd4d474a 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -114,6 +114,7 @@ Other rows gain workspace fields in a linked project: - Every event carries `client: "altimate-code"`, which names the product that sent it. `source` still says how the CLI was launched. - Rows of a turn in a linked project carry that project's `workspace_id`: `session_start`, `session_end`, `first_prompt_sent`, `first_job_completed`, `skill_used`, `skill_published`, `memory_injection`, `agent_outcome`, `task_classified`, `task_outcome_signal`, `tool_chain_outcome`, `core_failure`, `error_fingerprint`, `plan_revision`, `sql_quality` and `tool_call`. +- With `ALTIMATE_DISABLE_WORKSPACE` set, these rows still name a link the project already had, read from the local cache with no request, and that workspace's `first_prompt_sent` and `first_job_completed` are still sent once. A pin from the IDE extension is not read. The switch turns the feature off; it doesn't stop usage in a project linked earlier from being counted against its workspace. - `session_end` counts the workspace skills, memory blocks and integration calls the session used. `memory_injection` counts the workspace's blocks. `task_outcome_signal` says whether workspace context was used. - A call to a workspace integration through the engine is reported as a `tool_call` with `tool_type: "mcp"` and `via_workspace: true`. As for other tools, there are no arguments or outputs, and an error message is masked and capped at 500 characters. - An `endpoint` value is a route label with every id and path segment replaced by `:id`, for example `POST /datamate-project-bindings/bind`. It never contains an id, a file path or a query string. diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index d85627f1b5..3ed4365453 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -129,6 +129,7 @@ export const Flag = { // memory sync, the workspace engine). Workspaces are on by default; setting this turns every // part of the feature off and takes already-synced workspace skills out of service. Read as a getter so tests and the // runtime `--` middleware can flip it between plugin activation and command execution. + // Telemetry rows still name a link the project already had (docs/docs/reference/telemetry.md). // Unrelated to OPENCODE_EXPERIMENTAL_WORKSPACES (multi-instance control plane). get ALTIMATE_DISABLE_WORKSPACE() { return killSwitchSet(env("ALTIMATE_DISABLE_WORKSPACE")) From 01644f23e1846116d9a384945b45b2d5b9031b4e Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 11 Nov 2026 18:01:06 +0800 Subject: [PATCH 3/4] fix: an onboarding first-job mark uses the credential of the tool's own turn The trigger site notes the turn's credential under the call id while that call's plugins run, and the onboarding hook claims under it. A tenant switch mid-turn, or a late return, no longer drops the other tenant's row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- .../altimate/plugin/onboarding-telemetry.ts | 7 +++- .../src/altimate/workspace/first-events.ts | 24 +++++++++++ packages/opencode/src/session/prompt.ts | 13 +++++- .../altimate/telemetry/onboarding.test.ts | 33 +++++++++++++++ .../workspace-first-events-loop.test.ts | 40 ++++++++++++++++++- 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 549e482dbb..9d376def2e 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -270,8 +270,13 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() + +/** Notes `scope` for `callID` until the returned handle is disposed. */ +export function noteCallScope(callID: string | undefined, scope: Scope | undefined): Disposable { + if (callID === undefined || !scope) return { [Symbol.dispose]() {} } + callScopes.set(callID, scope) + return { + [Symbol.dispose]() { + if (callScopes.get(callID) === scope) callScopes.delete(callID) + }, + } +} + +/** The scope noted for a tool call, while its plugins run. */ +export function callScope(callID: string): Scope | undefined { + return callScopes.get(callID) +} + /** A typed prompt: the onboarding row and the workspace row are one row when both apply. */ export async function promptSent(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) @@ -106,5 +129,6 @@ export async function jobCompleted(sessionID: string, workspaceId: number | unde /** Test seam: drops this process's memory of the marks, as a new process would start. */ export function resetForTest() { sent.clear() + callScopes.clear() warnedUnwritable = false } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fe4cad3884..c45e18f964 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,13 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload // altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound // directories like the bindings cache does -import { canonicalDirectory, endTurnBinding, turnBinding, unvalidatedTurnBinding } from "@/altimate/workspace/state" +import { + canonicalDirectory, + endTurnBinding, + turnBinding, + unvalidatedTurnBinding, + type AccountKey, +} 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 @@ -1825,6 +1831,7 @@ export namespace SessionPrompt { // altimate_change start — file rules append only to this tool result lessons, workspaceId: boundWorkspace?.datamateId, + workspaceScope: boundWorkspace?.scope, turn: telemetryTurn, // altimate_change end }), @@ -2845,6 +2852,8 @@ export namespace SessionPrompt { /** 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 + /** The credential that turn's workspace was resolved under, noted for each tool call's plugins. */ + workspaceScope?: AccountKey turn?: number // altimate_change end }) { @@ -2970,6 +2979,8 @@ export namespace SessionPrompt { // altimate_change start — stamp authoritative tool source so clients render the right // badge. Shared with SessionTools.resolve (session/tools.ts) so the resolvers can't drift. const stamped = stampRegistryToolSource(output, item) + // The turn's credential, for this call's plugins: a first-event mark names its tenant. + using _callScope = WorkspaceFirstEvents.noteCallScope(ctx.callID, input.workspaceScope) // altimate_change end await Plugin.trigger( "tool.execute.after", diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 2e60295682..94969e6d91 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -264,6 +264,39 @@ describe("activation events", () => { }) }) + test("the first job's mark is the tool's own turn's tenant, when the credential switched before a late return", async () => { + await withTestStateHome(async () => { + spyOn(Telemetry, "isEnabled").mockImplementation(() => true) + spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) + // By the time the tool returns, the user has signed in to another tenant. + spyOn(AltimateApi, "getCredentials").mockImplementation( + async () => ({ altimateInstanceName: "other", altimateUrl: "https://api.test", altimateApiKey: "k2" }) as never, + ) + WorkspaceFirstEvents.resetForTest() + captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + const acme = { tenant: "acme", apiUrl: "https://api.test" } + const other = { tenant: "other", apiUrl: "https://api.test" } + + // The cancelled turn's call, and its replacement's, both still running: each trigger site notes + // its own turn's credential. + using _late = WorkspaceFirstEvents.noteCallScope("c3late", acme) + using _replacement = WorkspaceFirstEvents.noteCallScope("c9new", other) + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3late", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7, acme)).toBe(false) + // The other tenant's workspace 7 is a different workspace: its own first row is still sent. + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7, other)).toBe(true) + WorkspaceFirstEvents.resetForTest() + }) + }) + 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/session/workspace-first-events-loop.test.ts b/packages/opencode/test/session/workspace-first-events-loop.test.ts index 04831be65b..e27ee6c9c6 100644 --- a/packages/opencode/test/session/workspace-first-events-loop.test.ts +++ b/packages/opencode/test/session/workspace-first-events-loop.test.ts @@ -17,7 +17,13 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { Telemetry } from "../../src/altimate/telemetry" import * as OnboardingTelemetry from "../../src/altimate/telemetry/onboarding" import { AltimateApi } from "../../src/altimate/api/client" -import { __resetPinValidation, readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" +import { + __resetPinValidation, + currentScope, + readLocalBinding, + recordApprovedBinding, +} from "../../src/altimate/workspace/state" +import { Plugin } from "../../src/plugin" import * as WorkspaceFirstEvents from "../../src/altimate/workspace/first-events" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -137,6 +143,8 @@ type Creds = Awaited> let rows: Row[] = [] let restore: (() => void)[] = [] +/** The tenant the stubbed credential names. A test can switch it while a turn runs. */ +let tenant = "acme" let stateHome = "" const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME // The suite's preload turns the workspace feature off, and with it off a turn never reads the pin. These @@ -153,6 +161,7 @@ const inherited = Object.fromEntries( beforeEach(() => { rows = [] + tenant = "acme" for (const name of Object.keys(inherited)) delete process.env[name] __resetPinValidation() stateHome = mkdtempSync(path.join(os.tmpdir(), "first-events-loop-state-")) @@ -162,7 +171,7 @@ beforeEach(() => { const track = spyOn(Telemetry, "track").mockImplementation((event) => void rows.push(event as unknown as Row)) const enabled = spyOn(Telemetry, "isEnabled").mockImplementation(() => true) const creds = spyOn(AltimateApi, "getCredentials").mockImplementation( - async () => ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds, + async () => ({ altimateInstanceName: tenant, altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds, ) const configured = spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) restore = [ @@ -458,6 +467,33 @@ it.live("a subagent's working turn is not the workspace's job, and the parent's { timeout: 30_000 }, ) +it.live("a tool call's plugins get the credential its turn started under, not one switched to mid-turn", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + const seen: { turn?: string; now?: string }[] = [] + const trigger = Plugin.trigger + const spy = spyOn(Plugin, "trigger").mockImplementation((async (name: string, input: any, output: any) => { + // The user signs in to another tenant while the tool runs. + if (name === "tool.execute.before") tenant = "other" + if (name === "tool.execute.after") { + seen.push({ + turn: WorkspaceFirstEvents.callScope(input.callID)?.tenant, + now: (await currentScope())?.tenant, + }) + } + return (trigger as any)(name, input, output) + }) as never) + restore.push(() => spy.mockRestore()) + yield* turn(dir, llm, { tool: true }) + + expect(seen).toEqual([{ turn: "acme", now: "other" }]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + it.live("an unbound session sends neither row", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) { From 88756ea31664745901ba2ba7fccb237c94bb29c0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 12 Nov 2026 13:58:37 +0800 Subject: [PATCH 4/4] fix: with the workspace kill switch set, no first event names the project's link A project linked before the switch was set still sent that workspace's first_prompt_sent and first_job_completed. Now neither is sent, onboarding rows name no workspace, and nothing is marked, so the rows come once the switch is off. Other rows still name the link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- docs/docs/reference/telemetry.md | 2 +- packages/core/src/flag/flag.ts | 1 - .../altimate/plugin/onboarding-telemetry.ts | 9 ++-- .../src/altimate/workspace/first-events.ts | 20 ++++++++- .../altimate/telemetry/onboarding.test.ts | 41 +++++++++++++++++++ .../workspace-first-events-loop.test.ts | 38 +++++++++++++++++ 6 files changed, 103 insertions(+), 8 deletions(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index f2bd4d474a..a5aaa5d40b 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -114,7 +114,7 @@ Other rows gain workspace fields in a linked project: - Every event carries `client: "altimate-code"`, which names the product that sent it. `source` still says how the CLI was launched. - Rows of a turn in a linked project carry that project's `workspace_id`: `session_start`, `session_end`, `first_prompt_sent`, `first_job_completed`, `skill_used`, `skill_published`, `memory_injection`, `agent_outcome`, `task_classified`, `task_outcome_signal`, `tool_chain_outcome`, `core_failure`, `error_fingerprint`, `plan_revision`, `sql_quality` and `tool_call`. -- With `ALTIMATE_DISABLE_WORKSPACE` set, these rows still name a link the project already had, read from the local cache with no request, and that workspace's `first_prompt_sent` and `first_job_completed` are still sent once. A pin from the IDE extension is not read. The switch turns the feature off; it doesn't stop usage in a project linked earlier from being counted against its workspace. +- With `ALTIMATE_DISABLE_WORKSPACE` set, no `first_prompt_sent` or `first_job_completed` is sent for the project's saved link, and an onboarding session's own rows name no workspace. The turn's other rows still name that link, read from the local cache with no request; a pin from the IDE extension is not read. Once the switch is off, the workspace's first rows are sent as usual. - `session_end` counts the workspace skills, memory blocks and integration calls the session used. `memory_injection` counts the workspace's blocks. `task_outcome_signal` says whether workspace context was used. - A call to a workspace integration through the engine is reported as a `tool_call` with `tool_type: "mcp"` and `via_workspace: true`. As for other tools, there are no arguments or outputs, and an error message is masked and capped at 500 characters. - An `endpoint` value is a route label with every id and path segment replaced by `:id`, for example `POST /datamate-project-bindings/bind`. It never contains an id, a file path or a query string. diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3ed4365453..d85627f1b5 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -129,7 +129,6 @@ export const Flag = { // memory sync, the workspace engine). Workspaces are on by default; setting this turns every // part of the feature off and takes already-synced workspace skills out of service. Read as a getter so tests and the // runtime `--` middleware can flip it between plugin activation and command execution. - // Telemetry rows still name a link the project already had (docs/docs/reference/telemetry.md). // Unrelated to OPENCODE_EXPERIMENTAL_WORKSPACES (multi-instance control plane). get ALTIMATE_DISABLE_WORKSPACE() { return killSwitchSet(env("ALTIMATE_DISABLE_WORKSPACE")) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 9d376def2e..2699a60279 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -264,17 +264,18 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise { +export async function promptSent(sessionID: string, turnWorkspaceId: number | undefined, scope?: Scope): Promise { + const workspaceId = firstEventWorkspace(turnWorkspaceId) const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId, scope)) if (onboarding || inWorkspace) { @@ -116,7 +127,12 @@ export async function promptSent(sessionID: string, workspaceId: number | undefi } /** A turn in a workspace that ended `completed` with at least one tool call that didn't fail. */ -export async function jobCompleted(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { +export async function jobCompleted( + sessionID: string, + turnWorkspaceId: number | undefined, + scope?: Scope, +): Promise { + const workspaceId = firstEventWorkspace(turnWorkspaceId) if (workspaceId === undefined) return if (await claim("first_job_completed", workspaceId, scope)) { await OnboardingTelemetry.emit( diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 94969e6d91..25460f2784 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -123,6 +123,14 @@ async function runTool(hooks: any, input: any, output: any) { describe("activation events", () => { const SESSION = "ses_test" + // The suite's preload sets the workspace kill switch. A test that needs workspaces on, as a user's + // CLI has them, clears it, and it is put back after each test. + const KILL_SWITCH = process.env.ALTIMATE_DISABLE_WORKSPACE + const workspacesOn = () => delete process.env.ALTIMATE_DISABLE_WORKSPACE + afterEach(() => { + if (KILL_SWITCH === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = KILL_SWITCH + }) async function plugin() { return OnboardingTelemetryPlugin({} as any) @@ -221,6 +229,7 @@ describe("activation events", () => { }) test("the first job's row names the workspace the hook input carries", async () => { + workspacesOn() const events = captureEvents() const hooks = await plugin() await startOnboarding(hooks, "skip") @@ -240,6 +249,7 @@ describe("activation events", () => { }) test("the first job's row marks its workspace, so the workspace's own row isn't sent after it", async () => { + workspacesOn() await withTestStateHome(async () => { spyOn(Telemetry, "isEnabled").mockImplementation(() => true) spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) @@ -265,6 +275,7 @@ describe("activation events", () => { }) test("the first job's mark is the tool's own turn's tenant, when the credential switched before a late return", async () => { + workspacesOn() await withTestStateHome(async () => { spyOn(Telemetry, "isEnabled").mockImplementation(() => true) spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) @@ -297,6 +308,36 @@ describe("activation events", () => { }) }) + test("with the workspace kill switch set, the first job's row names no workspace and marks none", async () => { + await withTestStateHome(async () => { + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + spyOn(Telemetry, "isEnabled").mockImplementation(() => true) + spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) + spyOn(AltimateApi, "getCredentials").mockImplementation( + async () => ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as never, + ) + WorkspaceFirstEvents.resetForTest() + const events = captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3k", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + const completed = events.find((e) => e.type === "first_job_completed") as any + expect(completed.origin).toBe("onboarding") + expect(completed.workspace_id).toBeUndefined() + // Nothing was marked: with the switch off, the workspace's own first job is still sent. + workspacesOn() + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7)).toBe(true) + WorkspaceFirstEvents.resetForTest() + }) + }) + 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/session/workspace-first-events-loop.test.ts b/packages/opencode/test/session/workspace-first-events-loop.test.ts index e27ee6c9c6..602c296718 100644 --- a/packages/opencode/test/session/workspace-first-events-loop.test.ts +++ b/packages/opencode/test/session/workspace-first-events-loop.test.ts @@ -494,6 +494,44 @@ it.live("a tool call's plugins get the credential its turn started under, not on { timeout: 30_000 }, ) +it.live("with the workspace kill switch set, a linked project sends neither row, and both come once it is off", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + yield* turn(dir, llm, { tool: true }) + + expect(firstPrompts()).toEqual([]) + expect(firstJobs()).toEqual([]) + // The turn's other rows still name the saved link. + expect(named("task_outcome_signal").map((row) => row.workspace_id)).toEqual([LINKED]) + + delete process.env.ALTIMATE_DISABLE_WORKSPACE + yield* turn(dir, llm, { tool: true }) + + expect(firstPrompts()).toEqual([LINKED]) + expect(firstJobs()).toEqual([[LINKED, "something_else"]]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("with the workspace kill switch set, an onboarding session's row names no workspace", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + yield* turn(dir, llm, { before: (id) => OnboardingTelemetry.markOnboardingSession(id) }) + + expect(firstPrompts()).toEqual([undefined]) + expect(origins("first_prompt_sent")).toEqual(["onboarding"]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + it.live("an unbound session sends neither row", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) {