From 5724d15beeed8a054280817147e37950d4bb88c5 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 10 Nov 2026 18:04:13 +0800 Subject: [PATCH 1/3] fix: the workspace kill switch skips the pin's validation and wait With ALTIMATE_DISABLE_WORKSPACE on, routing never reads the pin, yet a turn's rows waited up to 3 s on a validation nothing used. They now name the project's link at once, as routing does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- packages/opencode/src/altimate/workspace/state.ts | 6 +++++- .../test/altimate/workspace/routing-pin.test.ts | 15 +++++++++++++++ .../test/session/pinned-workspace-loop.test.ts | 6 ++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 6b6e84f2fd..ce32678b36 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -24,7 +24,8 @@ import { Log } from "@/altimate/util/log" import type { Binding, ProjectBindingLookup } from "./api-client" import type { SeedOutcome } from "./memory-backfill" // altimate_change — the IDE extension's workspace pin; see ./pin.ts -import { readPinLogged, resolveWithinRoot, type ValidPin } from "./pin" +import { readPin, readPinLogged, resolveWithinRoot, type ValidPin } from "./pin" +import { isEnabled as workspaceEnabled } from "./engine-seams" import { resolveProjectIdentifier } from "./detect" const CACHE_VERSION = 2 @@ -768,6 +769,9 @@ export async function resolveTurnBinding( directory: string, 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`. + if (!workspaceEnabled()) return readLocalBinding(directory) let timer: ReturnType | undefined const deadline = new Promise((done) => { timer = setTimeout(() => done({ status: "unknown" }), waitMs) diff --git a/packages/opencode/test/altimate/workspace/routing-pin.test.ts b/packages/opencode/test/altimate/workspace/routing-pin.test.ts index 79480655dd..923ecb8f4b 100644 --- a/packages/opencode/test/altimate/workspace/routing-pin.test.ts +++ b/packages/opencode/test/altimate/workspace/routing-pin.test.ts @@ -218,6 +218,21 @@ describe("resolveTurnBinding", () => { expect((await routing)?.status).toBe("bound") expect(listCalls).toBe(1) }) + + test("with the workspace kill switch on, the pin is neither validated nor waited for: the link names the turn", async () => { + await seedLocalLink(7, "project-link") + // A validation that never answers: waiting on it would take the full turn wait. + ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = () => { + listCalls += 1 + return new Promise(() => {}) + } + setPin() + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + const started = Date.now() + expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(7) + expect(Date.now() - started).toBeLessThan(1_000) + expect(listCalls).toBe(0) + }) }) describe("turnBinding", () => { diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index 8fef319a3c..c76d640cff 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -142,9 +142,13 @@ let restore: (() => void)[] = [] let stateHome = "" const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME let inheritedPin: Record = {} +// The suite's preload turns the workspace feature off. With it off the turn never reads the pin, so +// these tests turn it on, and the kill-switch test turns it back off. +const originalKillSwitch = process.env.ALTIMATE_DISABLE_WORKSPACE beforeEach(() => { rows = [] + 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]])) for (const name of PIN_VARS) delete process.env[name] @@ -166,6 +170,8 @@ beforeEach(() => { afterEach(() => { for (const undo of restore) undo() + if (originalKillSwitch === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = originalKillSwitch for (const name of PIN_VARS) { const value = inheritedPin[name] if (value === undefined) delete process.env[name] From 1341f81c1e9bde08c5f2aa9a87df9c5cc4c7bcd6 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 10 Nov 2026 18:04:13 +0800 Subject: [PATCH 2/3] fix: `origin` on workspace first events, and the review's smaller fixes first_prompt_sent and first_job_completed carry origin (onboarding or workspace); rows without it are onboarding. Also: a subagent's turn is no job, a noReply attachment skips the pin wait, a turn ended early clears its verdict. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- docs/docs/reference/telemetry.md | 8 +- .../altimate/plugin/onboarding-telemetry.ts | 2 +- .../opencode/src/altimate/telemetry/index.ts | 6 + .../src/altimate/workspace/first-events.ts | 46 ++-- .../opencode/src/altimate/workspace/state.ts | 55 ++++- packages/opencode/src/session/prompt.ts | 30 ++- .../altimate/telemetry/onboarding.test.ts | 2 + .../altimate/workspace/first-events.test.ts | 12 ++ .../altimate/workspace/routing-pin.test.ts | 31 +++ .../session/pinned-workspace-loop.test.ts | 198 ++++++++++++++++-- .../workspace-first-events-loop.test.ts | 38 ++++ 11 files changed, 379 insertions(+), 49 deletions(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index b9207d7468..9069692107 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -70,8 +70,8 @@ We collect the following categories of events: | `environment_scan_completed` | A `project_scan` finished during onboarding — `has_dbt`, `has_warehouse`, `is_repo`, `connections_found`, and a bounded list of short `degraded` detection keys. No paths, hostnames, or connection details. Emitted only inside an onboarding session, and only once per session, so scans from `/discover` or a model-initiated call are excluded. | | `sample_setup_completed` | The sample dbt project was materialised. `success`, `models`, `tables`, and `reused` — the tool is deliberately re-callable, so this is per invocation. The target path is never sent. | | `activation_menu_shown` | The activation menu was (very likely) rendered. `variant` is `warehouse` or `no_data`. **Derived** — see the note below. | -| `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. In onboarding, completion is reported only for the job that was actually selected, so the two form a coherent pair. `first_job_completed` is also sent once per workspace for each machine and Altimate instance, for the first turn in a linked project that had a tool call that didn't fail and finished. **Derived** — see the note below. | -| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project, once per workspace for each machine and Altimate instance. Slash commands are excluded, so the hidden `/onboard-connect` submission does not count. | +| `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. In onboarding, completion is reported only for the job that was actually selected, so the two form a coherent pair. `first_job_completed` is also sent once per workspace for each machine and Altimate instance, for the first turn in a linked project, or in a session pinned to a workspace by the IDE extension, that had a tool call that didn't fail and ended `completed`. `origin` says which: `onboarding` or `workspace`. **Derived** — see the note below. | +| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project or a session pinned to a workspace, once per workspace for each machine and Altimate instance. A slash command sent as a command, as the TUI and `run` send it, is excluded, so the hidden `/onboard-connect` submission does not count. The VS Code chat sends a command as its expanded text, so there a command counts. A subagent's prompt never counts. `origin` is `onboarding` for an onboarding session's row, bound or not, and `workspace` otherwise. | | `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | @@ -138,7 +138,9 @@ They are therefore inferred from the closest deterministic signals — the menu - In onboarding, the "something else" branch has no tool signature at all and is never counted. - In onboarding, `first_job_completed` only fires for jobs with a real completion signal. Skill-driven jobs (downstream impact, SQL review, cost) load an instruction bundle and then do their work through other tools, so their completion is not observable and they are absent from this event rather than wrongly counted in it. -In a linked project, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which finished without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. What has been sent is remembered as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it. +In a linked project, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which ended `completed`, without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. A subagent's turn doesn't count on its own; the parent's turn counts it through its `task` call. Each row is claimed before it is sent, as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project, so the file records a claim, not a confirmed delivery. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it. + +Both rows carry `origin`: `onboarding` when an onboarding session sent the row, `workspace` when the workspace rule did. Rows from clients older than this field have no `origin`; they are onboarding rows. ## Delivery & Reliability diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index da6043e15b..549e482dbb 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -266,7 +266,7 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise /** What this process already knows is sent, so a bound turn skips the filesystem after the first. */ const sent = new Set() +/** Set once a mark couldn't be written, so a state directory that stays unwritable logs once, not + * every turn. The next turn still tries again. */ +let warnedUnwritable = false /** * True the one time this machine should send `event` for this workspace. @@ -38,9 +42,11 @@ const sent = new Set() * mark that can't be written claims nothing either, and the next turn tries again. The claim comes * before the send, so a process killed between the two loses the row rather than duplicating it. */ -export async function claim(event: FirstEvent, workspaceId: number): Promise { +export async function claim(event: FirstEvent, workspaceId: number, scope?: Scope): Promise { if (!Telemetry.isEnabled()) return false - const scope = await currentScope().catch(() => null) + // The credential the turn's binding was resolved under, when the caller has it: the workspace id + // belongs to that tenant, and the turn doesn't read the credentials again. + scope ??= (await currentScope().catch(() => null)) ?? undefined if (!scope) return false const id = createHash("sha256") .update(JSON.stringify([scope.tenant, scope.apiUrl, workspaceId, event])) @@ -49,34 +55,49 @@ export async function claim(event: FirstEvent, workspaceId: number): Promise undefined) + // here shows up on the write below, and is logged with it, since it is usually the cause. + const dirError = await mkdir(dir, { recursive: true }).then( + () => undefined, + (err: unknown) => String(err), + ) try { + // `wx` makes the create exclusive on every platform. `mode` has no effect on Windows, where the + // file takes the state directory's permissions. await writeFile(path.join(dir, id), "", { flag: "wx", mode: 0o600 }) sent.add(id) return true } catch (err) { if ((err as NodeJS.ErrnoException).code === "EEXIST") sent.add(id) - else log.warn("could not record a workspace first event", { event, err: String(err) }) + else if (!warnedUnwritable) { + warnedUnwritable = true + log.warn("could not record a workspace first event; later failures in this process are not logged", { + event, + err: String(err), + ...(dirError && { dirError }), + }) + } return false } } /** 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): Promise { +export async function promptSent(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) - const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId)) + const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId, scope)) if (onboarding || inWorkspace) { - await OnboardingTelemetry.emit({ type: "first_prompt_sent", workspace_id: workspaceId }, sessionID) + await OnboardingTelemetry.emit( + { type: "first_prompt_sent", workspace_id: workspaceId, origin: onboarding ? "onboarding" : "workspace" }, + sessionID, + ) } } /** 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): Promise { +export async function jobCompleted(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { if (workspaceId === undefined) return - if (await claim("first_job_completed", workspaceId)) { + if (await claim("first_job_completed", workspaceId, scope)) { await OnboardingTelemetry.emit( - { type: "first_job_completed", job: "something_else", workspace_id: workspaceId }, + { type: "first_job_completed", job: "something_else", workspace_id: workspaceId, origin: "workspace" }, sessionID, ) } @@ -85,4 +106,5 @@ 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() + warnedUnwritable = false } diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index ce32678b36..dac5ac4dd7 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -497,6 +497,8 @@ let pinNow: () => number = () => Date.now() /** Exposed for tests; production never calls either. */ export function __resetPinValidation(clock?: () => number): void { pinValidation.clear() + // A request an earlier test started could otherwise settle after the reset and fill the memo. + pinValidationInFlight.clear() pinNow = clock ?? (() => Date.now()) } @@ -650,7 +652,8 @@ async function resolvePinnedBinding(directory: string, pin: ValidPin): Promise { + const key = await accountKey() + if (!key) return null + const binding = await readCachedBinding(directory, key) + return binding ? { ...binding, scope: key } : null +} + /** The workspace a turn's telemetry rows name: the pin when there is one, else the project's link * from the local cache. * @@ -768,10 +786,10 @@ export const TURN_PIN_WAIT_MS = 3_000 export async function resolveTurnBinding( directory: string, waitMs = TURN_PIN_WAIT_MS, -): Promise { +): 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`. - if (!workspaceEnabled()) return readLocalBinding(directory) + if (!workspaceEnabled()) return linkedTurnBinding(directory) let timer: ReturnType | undefined const deadline = new Promise((done) => { timer = setTimeout(() => done({ status: "unknown" }), waitMs) @@ -780,14 +798,25 @@ export async function resolveTurnBinding( const pinned = await Promise.race([resolvePinnedBindingForRouting(directory), deadline]).finally(() => clearTimeout(timer), ) - if (pinned) return pinned.status === "bound" ? pinned.binding : null + if (pinned) { + if (pinned.status !== "bound") return null + return { ...pinned.binding, scope: (await accountKey()) ?? undefined } + } + return linkedTurnBinding(directory) +} + +/** The workspace a prompt's attachment reads name when no loop follows it (`noReply`). No turn uses + * the verdict, so the pin is neither validated nor waited for: a pinned session's reads name nothing + * rather than the project's link, and an unpinned one's name the link. */ +export async function unvalidatedTurnBinding(directory: string): Promise { + if (workspaceEnabled() && readPin().kind !== "absent") return null return readLocalBinding(directory) } /** Each session's verdict for its current turn, from the first read in the turn until the loop * starts. Bounded: a turn that fails before its loop leaves its entry until the session's next * turn clears it. */ -const turnVerdicts = new Map>() +const turnVerdicts = new Map>() const TURN_VERDICTS_MAX = 256 /** The workspace a session's current turn names. The first read in a turn resolves it and every @@ -798,10 +827,14 @@ export function turnBinding( sessionID: string, directory: string, waitMs = TURN_PIN_WAIT_MS, -): Promise { +): Promise { const known = turnVerdicts.get(sessionID) if (known) return known - const verdict = resolveTurnBinding(directory, waitMs).catch(() => null) + const verdict = resolveTurnBinding(directory, waitMs).catch((err) => { + // Logged, so "no workspace because of an error" reads differently from a refused pin. + log.warn("could not resolve the turn's workspace", { err: String(err) }) + return null + }) turnVerdicts.set(sessionID, verdict) if (turnVerdicts.size > TURN_VERDICTS_MAX) { const oldest = turnVerdicts.keys().next() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 758db13098..03558326bf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,7 @@ 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 } from "@/altimate/workspace/state" +import { canonicalDirectory, endTurnBinding, turnBinding, unvalidatedTurnBinding } 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 @@ -560,7 +560,11 @@ export namespace SessionPrompt { } // altimate_change end - const message = await createUserMessage(input) + // altimate_change — a prompt that fails here runs no loop, so no later loop may take its verdict + const message = await createUserMessage(input).catch((err) => { + endTurnBinding(input.sessionID) + throw err + }) await Session.touch(input.sessionID) // this is backwards compatibility for allowing `tools` to be specified when @@ -702,6 +706,9 @@ export namespace SessionPrompt { const abort = resume_existing ? resume(sessionID) : start(sessionID) if (!abort) { + // altimate_change — this prompt joins the running loop, which named its workspace when it + // started, so its verdict is left for no later loop (the summarize route, a resume) to take. + endTurnBinding(sessionID) return new Promise((resolve, reject) => { const callbacks = state()[sessionID].callbacks callbacks.push({ resolve, reject }) @@ -1968,7 +1975,7 @@ export namespace SessionPrompt { ) // A subagent's session starts from the command's template or the model's prompt, not a typed one. if (!fromCommand && hasUserText && !session.parentID) { - await WorkspaceFirstEvents.promptSent(sessionID, boundWorkspace?.datamateId) + await WorkspaceFirstEvents.promptSent(sessionID, boundWorkspace?.datamateId, boundWorkspace?.scope) } // altimate_change end // altimate_change end — session start telemetry @@ -2633,8 +2640,9 @@ export namespace SessionPrompt { ? "abandoned" : "completed" // altimate_change start — emit quality signal, tool chain, and error fingerprint events - if (outcome === "completed" && toolCallCount > toolErrorCount) { - await WorkspaceFirstEvents.jobCompleted(sessionID, boundWorkspace?.datamateId) + // A subagent's turn is not the workspace's job: the parent's turn counts it through its `task` call. + if (outcome === "completed" && toolCallCount > toolErrorCount && !session.parentID) { + await WorkspaceFirstEvents.jobCompleted(sessionID, boundWorkspace?.datamateId, boundWorkspace?.scope) } Telemetry.track({ type: "task_outcome_signal", @@ -3180,6 +3188,14 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { + // altimate_change start — the workspace this message's attachment reads name: the turn's, or for a + // `noReply` prompt, which runs no turn, one read without the pin's validation or wait. + const attachmentWorkspace = async () => + (input.noReply === true + ? await unvalidatedTurnBinding(Instance.directory) + : await turnBinding(input.sessionID, Instance.directory) + )?.datamateId + // altimate_change end const agentName = input.agent ?? (await Agent.defaultAgent()) const agent = await Agent.get(agentName) if (!agent) { @@ -3394,7 +3410,7 @@ export namespace SessionPrompt { const readCtx: Tool.Context = { sessionID: input.sessionID, // altimate_change — the turn's workspace, so the attachment read's tool rows name it - workspaceId: (await turnBinding(input.sessionID, Instance.directory))?.datamateId, + workspaceId: await attachmentWorkspace(), abort: new AbortController().signal, agent: input.agent!, messageID: info.id, @@ -3463,6 +3479,8 @@ export namespace SessionPrompt { const args = { filePath: filepath } const listCtx: Tool.Context = { sessionID: input.sessionID, + // altimate_change — the turn's workspace, as on a file attachment's read + workspaceId: await attachmentWorkspace(), abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index b82d499e6c..2e60295682 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -216,6 +216,8 @@ describe("activation events", () => { expect((events.find((e) => e.type === "activation_job_selected") as any).job).toBe("sample_duck_db") expect((events.find((e) => e.type === "first_job_completed") as any).job).toBe("sample_duck_db") + // Told apart from the workspace's own first job, which has the same name and shape. + expect((events.find((e) => e.type === "first_job_completed") as any).origin).toBe("onboarding") }) test("the first job's row names the workspace the hook input carries", async () => { diff --git a/packages/opencode/test/altimate/workspace/first-events.test.ts b/packages/opencode/test/altimate/workspace/first-events.test.ts index d2272d5cde..4803fe723c 100644 --- a/packages/opencode/test/altimate/workspace/first-events.test.ts +++ b/packages/opencode/test/altimate/workspace/first-events.test.ts @@ -74,6 +74,18 @@ describe("first-event claim", () => { expect(await claim("first_prompt_sent", 7)).toBe(false) }) + test("a claim given the turn's account is that tenant's, not the credential read at claim time", async () => { + // The turn resolved its workspace under acme; the machine has signed in to another tenant since. + restore.pop()?.() + signInTo("other") + expect(await claim("first_prompt_sent", 7, { tenant: "acme", apiUrl: "https://api.test" })).toBe(true) + // The same id under the credential read now is another tenant's workspace, still unclaimed. + expect(await claim("first_prompt_sent", 7)).toBe(true) + restore.pop()?.() + signInTo("acme") + expect(await claim("first_prompt_sent", 7)).toBe(false) + }) + test("nothing is claimed while telemetry is off, so opting back in can still send", async () => { const off = spyOn(Telemetry, "isEnabled").mockImplementation(() => false) expect(await claim("first_prompt_sent", 7)).toBe(false) diff --git a/packages/opencode/test/altimate/workspace/routing-pin.test.ts b/packages/opencode/test/altimate/workspace/routing-pin.test.ts index 923ecb8f4b..c6a761cdea 100644 --- a/packages/opencode/test/altimate/workspace/routing-pin.test.ts +++ b/packages/opencode/test/altimate/workspace/routing-pin.test.ts @@ -20,6 +20,7 @@ process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") const { resolvePinnedBindingForRouting, resolveTurnBinding, + unvalidatedTurnBinding, turnBinding, endTurnBinding, recordApprovedBinding, @@ -233,6 +234,36 @@ describe("resolveTurnBinding", () => { expect(Date.now() - started).toBeLessThan(1_000) expect(listCalls).toBe(0) }) + + test("the turn's workspace carries the account it was resolved under, from the link or the pin", async () => { + await seedLocalLink(7, "project-link") + expect((await resolveTurnBinding(ROOT))?.scope).toMatchObject({ tenant: "acme", apiUrl: "https://api.test" }) + setPin() + expect((await resolveTurnBinding(ROOT))?.scope).toMatchObject({ tenant: "acme", apiUrl: "https://api.test" }) + }) +}) + +// The attachment reads of a `noReply` prompt, which runs no turn. +describe("unvalidatedTurnBinding", () => { + test("in a pinned session the reads name nothing, with no validation request", async () => { + await seedLocalLink(7, "project-link") + setPin() + expect(await unvalidatedTurnBinding(ROOT)).toBeNull() + expect(listCalls).toBe(0) + }) + + test("in an unpinned session the reads name the project's link", async () => { + await seedLocalLink(7, "project-link") + expect((await unvalidatedTurnBinding(ROOT))?.datamateId).toBe(7) + expect(listCalls).toBe(0) + }) + + test("with the kill switch on, a pinned session's reads name the project's link", async () => { + await seedLocalLink(7, "project-link") + setPin() + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + expect((await unvalidatedTurnBinding(ROOT))?.datamateId).toBe(7) + }) }) describe("turnBinding", () => { diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index c76d640cff..4ae7254553 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -20,8 +20,14 @@ import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Telemetry } from "../../src/altimate/telemetry" import { AltimateApi } from "../../src/altimate/api/client" -import { WorkspaceApi } from "../../src/altimate/workspace/api-client" -import { __resetPinValidation, readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" +import { ForbiddenError, WorkspaceApi, WorkspaceApiError } from "../../src/altimate/workspace/api-client" +import { + __resetPinValidation, + PIN_VALIDATION_TTL_MS, + readLocalBinding, + recordApprovedBinding, + TURN_PIN_WAIT_MS, +} from "../../src/altimate/workspace/state" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" import { testEffect } from "../lib/effect" import { TestLLMServer } from "../lib/llm-server" @@ -261,7 +267,7 @@ it.live("a pinned session with a different on-disk link names the pinned workspa }), { git: true, config: providerCfg }, ), - { timeout: 30_000 }, + { timeout: 60_000 }, ) it.live("a pinned session with no on-disk link still names the pinned workspace", () => @@ -276,7 +282,7 @@ it.live("a pinned session with no on-disk link still names the pinned workspace" }), { git: true, config: providerCfg }, ), - { timeout: 30_000 }, + { timeout: 60_000 }, ) it.live("a pin this account cannot see names no workspace, not the project's link", () => @@ -293,26 +299,186 @@ it.live("a pin this account cannot see names no workspace, not the project's lin }), { git: true, config: providerCfg }, ), - { timeout: 30_000 }, + { timeout: 60_000 }, ) -it.live("attached files add no check of a refused pin: the turn resolves its workspace once", () => +// On `main` the attachment reads name the project's link whatever the pin says; here they take the +// turn's verdict. That the verdict is resolved once however many reads a turn makes is pinned in +// routing-pin.test.ts ("a refused pin is checked once per turn"). +it.live("attached files name the turn's verdict on a refused pin, not the project's link", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) { - // A refusal is never memoized, so every resolution that isn't shared asks the account again. - const asked = accountSees([{ id: LINKED, name: "project-link" }]) + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) pinTo(dir) - yield* runTurn(dir, llm, 0) - const withoutFiles = asked() - expect(withoutFiles).toBeGreaterThan(0) yield* runTurn(dir, llm, 3) - expect(asked() - withoutFiles).toBe(withoutFiles) - expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined, undefined, undefined, undefined]) + expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined, undefined, undefined]) + expect(rows.filter((row) => row.workspace_id === LINKED)).toEqual([]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + +/** A prompt in a new pinned session with only `parts`, and its exit: no model call is queued. */ +const promptOnly = (parts: SessionPrompt.PromptInput["parts"], noReply?: boolean) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "pinned session", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + return yield* Effect.exit(prompt.prompt({ sessionID: session.id, agent: "build", noReply, parts })) + }) + +it.live("a directory attachment's read names the turn's workspace, as a file's does", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir }) { + accountSees([{ id: PINNED, name: "pinned-workspace" }]) + yield* linkProject(dir) + pinTo(dir) + // A missing directory: a failed read is the one that sends a `tool_call` row. The prompt fails + // with it, before the loop. + yield* promptOnly([ + { type: "text", text: "list this" }, + { + type: "file", + url: pathToFileURL(path.join(dir, "missing-dir")).href, + filename: "missing-dir", + mime: "application/x-directory", + }, + ]) + + expect(reads().map((row) => row.workspace_id)).toEqual([PINNED]) + }), + { 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 }) { + const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation( + async () => + [ + { id: PINNED, name: "pinned-workspace" }, + { id: LINKED, name: "project-link" }, + ] as never, + ) + restore.push(() => list.mockRestore()) + yield* linkProject(dir) + pinTo(dir) + yield* promptOnly( + [ + { type: "text", text: "for later" }, + { type: "file", url: pathToFileURL(path.join(dir, "missing-0.txt")).href, filename: "missing-0.txt", mime: "text/plain" }, + ], + true, + ) + + expect(reads().map((row) => row.workspace_id)).toEqual([undefined]) + // Memory sync's own question (is memory on?) passes no credential; the pin's validation does. + expect(list.mock.calls.filter(([actAs]) => actAs !== undefined).length).toBe(0) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + +it.live("with the workspace kill switch on, the pin is not checked and the rows name the project's link", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + const asked = accountSees([ + { id: PINNED, name: "pinned-workspace" }, + { id: LINKED, name: "project-link" }, + ]) + yield* linkProject(dir) + pinTo(dir) + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + yield* runTurn(dir, llm) + + expect(asked()).toBe(0) + expect(named("session_start").map((row) => row.workspace_id)).toEqual([LINKED]) + expect(reads().map((row) => row.workspace_id)).toEqual([LINKED, LINKED]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + +it.live("a validation slower than the turn's wait leaves the turn's rows unattributed, with one request", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation(async () => { + await new Promise((done) => setTimeout(done, TURN_PIN_WAIT_MS + 500)) + return [{ id: PINNED, name: "pinned-workspace" }] as never + }) + restore.push(() => list.mockRestore()) + yield* linkProject(dir) + pinTo(dir) + yield* runTurn(dir, llm) + + // Routing and identity join the request the turn started rather than sending their own. The pin's + // validation acts as a given credential; memory sync's own question (is memory on?) passes none. + expect(list.mock.calls.filter(([actAs]) => actAs !== undefined).length).toBe(1) + expect(named("session_start").map((row) => row.workspace_id)).toEqual([undefined]) + expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined]) + expect(rows.filter((row) => row.workspace_id === LINKED)).toEqual([]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + +it.live("a pin validated earlier keeps naming the turn while the account is unreachable", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + let now = Date.now() + __resetPinValidation(() => now) + let reachable = true + const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation(async () => { + if (!reachable) throw new WorkspaceApiError("Cannot reach the account") + return [{ id: PINNED, name: "pinned-workspace" }] as never + }) + restore.push(() => list.mockRestore()) + yield* linkProject(dir) + pinTo(dir) + const session = yield* runTurn(dir, llm) + // Past the validation memo, inside the grace window, and the account no longer answers. + now += PIN_VALIDATION_TTL_MS + 1 + reachable = false + rows = [] + yield* runTurn(dir, llm, 1, session) + + expect(named("task_outcome_signal").map((row) => row.workspace_id)).toEqual([PINNED]) + expect(reads().map((row) => row.workspace_id)).toEqual([PINNED, PINNED]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + +it.live("a validation refused with a 403 names no workspace, not the project's link", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + const list = spyOn(WorkspaceApi, "listDatamates").mockImplementation(async () => { + throw new ForbiddenError() + }) + restore.push(() => list.mockRestore()) + yield* linkProject(dir) + pinTo(dir) + yield* runTurn(dir, llm) + + expect(named("session_start").map((row) => row.workspace_id)).toEqual([undefined]) + expect(reads().map((row) => row.workspace_id)).toEqual([undefined, undefined]) + expect(rows.filter((row) => row.workspace_id === LINKED)).toEqual([]) }), { git: true, config: providerCfg }, ), - { timeout: 30_000 }, + { timeout: 60_000 }, ) it.live("a later turn of the same session names the workspace it resolves, not the first turn's", () => @@ -336,7 +502,7 @@ it.live("a later turn of the same session names the workspace it resolves, not t }), { git: true, config: providerCfg }, ), - { timeout: 40_000 }, + { timeout: 60_000 }, ) it.live("an unpinned session keeps naming the project's link", () => @@ -351,5 +517,5 @@ it.live("an unpinned session keeps naming the project's link", () => }), { git: true, config: providerCfg }, ), - { timeout: 30_000 }, + { timeout: 60_000 }, ) 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 c904b12ecd..6a7a8294c9 100644 --- a/packages/opencode/test/session/workspace-first-events-loop.test.ts +++ b/packages/opencode/test/session/workspace-first-events-loop.test.ts @@ -126,6 +126,7 @@ const LINKED = 7 type Row = { type: string workspace_id?: number + origin?: string job?: string status?: string signal?: string @@ -208,6 +209,7 @@ const turn = ( const named = (type: string) => rows.filter((row) => row.type === type) const firstPrompts = () => named("first_prompt_sent").map((row) => row.workspace_id) const firstJobs = () => named("first_job_completed").map((row) => [row.workspace_id, row.job]) +const origins = (type: string) => named(type).map((row) => row.origin) it.live("a bound session sends both rows, naming the workspace", () => provideTmpdirServerLegacy( @@ -217,6 +219,8 @@ it.live("a bound session sends both rows, naming the workspace", () => expect(firstPrompts()).toEqual([LINKED]) expect(firstJobs()).toEqual([[LINKED, "something_else"]]) + expect(origins("first_prompt_sent")).toEqual(["workspace"]) + expect(origins("first_job_completed")).toEqual(["workspace"]) }), { git: true, config: providerCfg }, ), @@ -405,6 +409,38 @@ it.live("a subagent's prompt is not a first prompt, and the parent's next typed { timeout: 30_000 }, ) +it.live("a subagent's working turn is not the workspace's job, and the parent's next working turn is", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const parent = yield* sessions.create({ + title: "parent", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const child = yield* sessions.create({ + parentID: parent.id, + title: "child", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const file = path.join(dir, "note.txt") + yield* Effect.promise(() => Bun.write(file, "note")) + yield* llm.tool("read", { filePath: file }) + yield* llm.text("child answer") + yield* prompt.prompt({ sessionID: child.id, agent: "build", parts: [{ type: "text", text: "review the diff" }] }) + expect(firstJobs()).toEqual([]) + + yield* llm.tool("read", { filePath: file }) + yield* llm.text("parent answer") + yield* prompt.prompt({ sessionID: parent.id, agent: "build", parts: [{ type: "text", text: "typed" }] }) + expect(firstJobs()).toEqual([[LINKED, "something_else"]]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + it.live("an unbound session sends neither row", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) { @@ -426,6 +462,7 @@ it.live("an onboarding session in a bound project sends one row, and a later ses yield* turn(dir, llm) expect(firstPrompts()).toEqual([LINKED]) + expect(origins("first_prompt_sent")).toEqual(["onboarding"]) }), { git: true, config: providerCfg }, ), @@ -438,6 +475,7 @@ it.live("an onboarding session in an unbound project still sends its row", () => yield* turn(dir, llm, { before: (id) => OnboardingTelemetry.markOnboardingSession(id) }) expect(firstPrompts()).toEqual([undefined]) + expect(origins("first_prompt_sent")).toEqual(["onboarding"]) }), { git: true, config: providerCfg }, ), From 0909886de0e850069b69fa647c436ed6e05610e4 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 10 Nov 2026 19:49:02 +0800 Subject: [PATCH 3/3] fix: a pinned turn keeps the credential that validated its pin The pin's verdict now carries the credential it was validated under, so a switch during validation can't key the first-event mark under another tenant. Also: `origin` is described by the rule that sent the row, and a flaky timing check is dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8 --- docs/docs/reference/telemetry.md | 6 +++--- .../opencode/src/altimate/telemetry/index.ts | 4 ++-- .../opencode/src/altimate/workspace/state.ts | 9 +++++++-- .../altimate/workspace/routing-pin.test.ts | 18 +++++++++++++++--- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 9069692107..a421757654 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -71,7 +71,7 @@ We collect the following categories of events: | `sample_setup_completed` | The sample dbt project was materialised. `success`, `models`, `tables`, and `reused` — the tool is deliberately re-callable, so this is per invocation. The target path is never sent. | | `activation_menu_shown` | The activation menu was (very likely) rendered. `variant` is `warehouse` or `no_data`. **Derived** — see the note below. | | `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. In onboarding, completion is reported only for the job that was actually selected, so the two form a coherent pair. `first_job_completed` is also sent once per workspace for each machine and Altimate instance, for the first turn in a linked project, or in a session pinned to a workspace by the IDE extension, that had a tool call that didn't fail and ended `completed`. `origin` says which: `onboarding` or `workspace`. **Derived** — see the note below. | -| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project or a session pinned to a workspace, once per workspace for each machine and Altimate instance. A slash command sent as a command, as the TUI and `run` send it, is excluded, so the hidden `/onboard-connect` submission does not count. The VS Code chat sends a command as its expanded text, so there a command counts. A subagent's prompt never counts. `origin` is `onboarding` for an onboarding session's row, bound or not, and `workspace` otherwise. | +| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project or a session pinned to a workspace, once per workspace for each machine and Altimate instance. A slash command sent as a command, as the TUI and `run` send it, is excluded, so the hidden `/onboard-connect` submission does not count. The VS Code chat sends a command as its expanded text, so there a command counts. A subagent's prompt never counts. `origin` says which rule sent it: `onboarding` for an onboarding session's first typed prompt, bound or not, and `workspace` for the workspace's row. So a later prompt in an onboarding session, after its project was linked, is a `workspace` row. | | `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | @@ -138,9 +138,9 @@ They are therefore inferred from the closest deterministic signals — the menu - In onboarding, the "something else" branch has no tool signature at all and is never counted. - In onboarding, `first_job_completed` only fires for jobs with a real completion signal. Skill-driven jobs (downstream impact, SQL review, cost) load an instruction bundle and then do their work through other tools, so their completion is not observable and they are absent from this event rather than wrongly counted in it. -In a linked project, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which ended `completed`, without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. A subagent's turn doesn't count on its own; the parent's turn counts it through its `task` call. Each row is claimed before it is sent, as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project, so the file records a claim, not a confirmed delivery. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it. +In a linked project, or a session pinned to a workspace by the IDE extension, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which ended `completed`, without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. A subagent's turn doesn't count on its own; the parent's turn counts it through its `task` call. Each row is claimed before it is sent, as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project, so the file records a claim, not a confirmed delivery. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it. -Both rows carry `origin`: `onboarding` when an onboarding session sent the row, `workspace` when the workspace rule did. Rows from clients older than this field have no `origin`; they are onboarding rows. +Both rows carry `origin`: `onboarding` when the onboarding rule sent the row, `workspace` when the workspace rule did. Rows from clients older than this field have no `origin`; they are onboarding rows. ## Delivery & Reliability diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index d9dea6b1c5..750f641264 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1557,8 +1557,8 @@ export namespace Telemetry { type: "first_prompt_sent" /** altimate_change — the turn's workspace, named by the emitter */ workspace_id?: number - /** altimate_change — which rule sent the row: `onboarding` when the session is an onboarding one, - * bound or not, else `workspace`. Rows from older clients have none and are onboarding rows. */ + /** altimate_change — which rule sent the row: `onboarding` for an onboarding session's first typed + * prompt, bound or not, else `workspace`. Rows from older clients have none and are onboarding rows. */ origin: "onboarding" | "workspace" timestamp: number session_id: string diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index dac5ac4dd7..24f62d57a6 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -461,6 +461,9 @@ export type BindingOutcome = * or rebound elsewhere. Absent when the server confirmed it inside the * revalidation window. */ stale?: true + /** altimate_change — the credential the pin's verdict was reached under, set by the pin path, so a + * caller that keys on the tenant needn't read the credentials again after a switch. */ + scope?: AccountKey } | { status: "unbound" @@ -497,7 +500,8 @@ let pinNow: () => number = () => Date.now() /** Exposed for tests; production never calls either. */ export function __resetPinValidation(clock?: () => number): void { pinValidation.clear() - // A request an earlier test started could otherwise settle after the reset and fill the memo. + // A caller after the reset starts its own request rather than joining one from before it. (A + // caller already awaiting that request still writes the memo when it answers.) pinValidationInFlight.clear() pinNow = clock ?? (() => Date.now()) } @@ -725,6 +729,7 @@ async function resolvePinnedBinding(directory: string, pin: ValidPin): Promise { test("with the workspace kill switch on, the pin is neither validated nor waited for: the link names the turn", async () => { await seedLocalLink(7, "project-link") - // A validation that never answers: waiting on it would take the full turn wait. + // A validation that never answers: a turn that asked would reach its deadline with no workspace. ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = () => { listCalls += 1 return new Promise(() => {}) } setPin() process.env.ALTIMATE_DISABLE_WORKSPACE = "1" - const started = Date.now() expect((await resolveTurnBinding(ROOT))?.datamateId).toBe(7) - expect(Date.now() - started).toBeLessThan(1_000) expect(listCalls).toBe(0) }) @@ -241,6 +239,20 @@ describe("resolveTurnBinding", () => { setPin() expect((await resolveTurnBinding(ROOT))?.scope).toMatchObject({ tenant: "acme", apiUrl: "https://api.test" }) }) + + test("a credential switch while the pin is validated leaves the turn on the account that validated it", async () => { + setPin() + ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = async () => { + listCalls += 1 + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ altimateInstanceName: "other", altimateUrl: "https://api.test", altimateApiKey: "k2" }) as Creds + return [{ id: 42, name: "pinned-workspace" }] + } + const turn = await resolveTurnBinding(ROOT) + expect(turn?.datamateId).toBe(42) + expect(turn?.scope).toMatchObject({ tenant: "acme" }) + expect(listCalls).toBe(1) + }) }) // The attachment reads of a `noReply` prompt, which runs no turn.