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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
Expand Down
14 changes: 10 additions & 4 deletions packages/opencode/src/altimate/plugin/onboarding-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,20 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Ho
) {
// altimate_change — the workspace of the turn that ran the tool, carried by the hook input
// from the trigger site: a tool returning after its turn was cancelled and replaced still
// names its own turn's workspace, not the replacement's.
// names its own turn's workspace, not the replacement's. None with the kill switch set.
const workspaceId = WorkspaceFirstEvents.firstEventWorkspace(input.workspaceId)
void OnboardingTelemetry.emit(
{ type: "first_job_completed", job, workspace_id: input.workspaceId, origin: "onboarding" },
{ type: "first_job_completed", job, workspace_id: workspaceId, origin: "onboarding" },
input.sessionID,
)
// Marked so the end of this turn, or a later session, doesn't send the workspace's row as well.
if (input.workspaceId !== undefined) {
await WorkspaceFirstEvents.claim("first_job_completed", input.workspaceId)
// Under the credential of the turn that ran the tool, which its workspace id belongs to.
if (workspaceId !== undefined) {
await WorkspaceFirstEvents.claim(
"first_job_completed",
workspaceId,
WorkspaceFirstEvents.callScope(input.callID),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: callScope(input.callID) is only populated on the native registry path, so this claim silently falls back to the credential at tool-return time on other paths.

WorkspaceFirstEvents.noteCallScope is called only in resolveTools's native tool wrapper (packages/opencode/src/session/prompt.ts:2983). The MCP wrapper also fires tool.execute.after (prompt.ts:3090-3100) and the task tool fires it too (prompt.ts:1420-1430), but neither notes a scope. The MCP loop assigns tools[key] = item after the native loop (prompt.ts:3163), so an MCP server exposing a tool named sample_setup replaces the native one and reaches this hook through the un-noted path. callScope is then undefined, and claim() falls back to currentScope() (packages/opencode/src/altimate/workspace/first-events.ts:53), which reads the credential now — the exact cross-tenant read this PR removes.

This is latent today because sample_setup is native, but the new mechanism's invariant is "the trigger site notes the scope". Noting it at the MCP (and task) trigger sites, or deriving the scope from the turn context rather than a single call site, keeps the invariant true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined, recorded as R7 on the review log: not reachable for this mark.

Only sample_setup completes a job (isJobCompletion, onboarding-telemetry.ts:183), and only the native tool reaches it by that name. The task trigger isn't a job (jobForTool maps no task). An MCP tool's key is <server>_<tool> (mcp/index.ts:1251), so it would take a server named sample with a tool named setup. Even then the MCP trigger passes the raw call result, which carries no metadata.success, so isJobCompletion is false and nothing is claimed. If a job-completing tool is ever added on another path, its trigger site should note the scope as the native one does.

)
}
}
},
Expand Down
44 changes: 42 additions & 2 deletions packages/opencode/src/altimate/workspace/first-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
// agent does the work with other tools. So a job here is a turn with at least one tool call that
// didn't fail, ending `completed`, the outcome `task_outcome_signal` reports as `accepted`. It
// reports as `something_else` because nothing says which job it was.
//
// With the workspace kill switch set, no first event names the project's saved link, so a project
// linked before the switch was set isn't counted as that workspace's first prompt or job.
import { createHash } from "node:crypto"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@/global"
import { Log } from "@/altimate/util/log"
import { Telemetry } from "@/altimate/telemetry"
Expand Down Expand Up @@ -80,8 +84,38 @@ export async function claim(event: FirstEvent, workspaceId: number, scope?: Scop
}
}

/** The credential scope of the turn each tool call ran in, while that call's `tool.execute.after`
* plugins run. The trigger site notes it from its own turn, so a mark made there names the tenant the
* call's workspace id belongs to, even when the credential has switched since, or the turn was
* cancelled and replaced before the tool returned. Keyed by call id, not session: a session's later
* turn has its own calls. */
const callScopes = new Map<string, Scope>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: callScopes overwrites an earlier scope whenever overlapping tool executions reuse the same callID, so a late tool.execute.after can claim the workspace under the replacement turn's tenant or fall back to the current tenant. Key the scope by an execution-unique token (or retain per-call entries) rather than relying on provider call IDs being unique across concurrent turns.

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

<comment>`callScopes` overwrites an earlier scope whenever overlapping tool executions reuse the same `callID`, so a late `tool.execute.after` can claim the workspace under the replacement turn's tenant or fall back to the current tenant. Key the scope by an execution-unique token (or retain per-call entries) rather than relying on provider call IDs being unique across concurrent turns.</comment>

<file context>
@@ -80,8 +84,38 @@ export async function claim(event: FirstEvent, workspaceId: number, scope?: Scop
+ * call's workspace id belongs to, even when the credential has switched since, or the turn was
+ * cancelled and replaced before the tool returned. Keyed by call id, not session: a session's later
+ * turn has its own calls. */
+const callScopes = new Map<string, Scope>()
+
+/** Notes `scope` for `callID` until the returned handle is disposed. */
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined for this PR, recorded as R5 on the review log.

The overwrite needs two tool calls running at the same time in one process with the same call id. The id is the provider's toolCallId. sanitizeToolCallID replaces only a missing or non-string one, and the AI SDK providers return or generate unique ids. Even with a repeated id, the wrong tenant is taken only when the two calls' turns ran under different credentials, and only for an onboarding sample-setup completion. With the same credential, the fallback to the current credential names the same tenant.

Keying by something unique to each execution would mean adding it to the plugin hook input, which this PR keeps unchanged, or carrying it in async context. Either is a bigger change than this case warrants before this release.


/** 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (Codex). The key is the raw options.toolCallId (prompt.ts:2870), and processor.ts:158 says a provider may repeat one malformed raw id for concurrent calls. So R5's "the AI SDK makes them unique" doesn't hold for this repo.

When two overlapping calls share an id under different turn credentials, the second set overwrites the first. If the second call disposes first, the first call's lookup returns undefined and claim falls back to the claim-time credential.

This is an edge input that costs one first-event attribution. Safe in a follow-up, for example by keying on the processor's execution identity.

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)
}

/** The workspace a first event may name: none while the workspace kill switch is set. Nothing is
* marked then either, so the workspace's rows are still sent once the switch is off. */
export function firstEventWorkspace(workspaceId: number | undefined): number | undefined {
return Flag.ALTIMATE_DISABLE_WORKSPACE ? undefined : workspaceId
}

/** 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<void> {
export async function promptSent(sessionID: string, turnWorkspaceId: number | undefined, scope?: Scope): Promise<void> {
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) {
Expand All @@ -93,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<void> {
export async function jobCompleted(
sessionID: string,
turnWorkspaceId: number | undefined,
scope?: Scope,
): Promise<void> {
const workspaceId = firstEventWorkspace(turnWorkspaceId)
if (workspaceId === undefined) return
if (await claim("first_job_completed", workspaceId, scope)) {
await OnboardingTelemetry.emit(
Expand All @@ -106,5 +145,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
}
3 changes: 2 additions & 1 deletion packages/opencode/src/altimate/workspace/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,8 @@ export async function resolveTurnBinding(
waitMs = TURN_PIN_WAIT_MS,
): Promise<TurnBinding | null> {
// 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<typeof setTimeout> | undefined
const deadline = new Promise<BindingOutcome>((done) => {
Expand Down
29 changes: 26 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}),
Expand Down Expand Up @@ -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
}) {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (Codex). Disposal happens only when this block exits. A tool.execute.after hook that never settles keeps its entry, because cancelling the session doesn't unwind a pending hook. That leaves one entry per hung hook, with no hard cap. It's an edge input: a hung hook already hangs this call on main. Success and thrown-error exits dispose correctly.

// altimate_change end
await Plugin.trigger(
"tool.execute.after",
Expand Down Expand Up @@ -3195,6 +3206,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<T>(results: PromiseSettledResult<T>[]): T[] {
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (Codex). find picks the first rejected part in input order. Promise.all reported the first rejection in time. Example: a missing directory first and a malformed URL second. Main shows the URL error; head waits and shows the directory error. This only changes which diagnostic is shown, so it's cosmetic, but the PR's "error is still the first failure" doesn't hold literally.

if (failed) throw failed.reason
return results.map((result) => (result as PromiseFulfilledResult<T>).value)
}
// altimate_change end
const agentName = input.agent ?? (await Agent.defaultAgent())
const agent = await Agent.get(agentName)
Expand Down Expand Up @@ -3250,7 +3269,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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: A failed prompt now waits for every attachment, so an attachment that never settles withholds the error indefinitely.

Promise.allSettled removes the fail-fast behaviour: createUserMessage no longer rejects until every part settles. The ranged text/plain attachment part awaits LSP.documentSymbol (packages/opencode/src/session/prompt.ts:3397), which runs client.connection.sendRequest("textDocument/documentSymbol", ...) with no timeout (packages/opencode/src/lsp/lsp.ts:301-304,427-433); the .catch(() => []) there only handles rejection, not a promise that never settles, and ctx.abort for these reads is a fresh never-aborted AbortController (prompt.ts:3435). So with one failing sibling part (e.g. the missing-directory read) and a hung LSP server or unresponsive language server, the failure used to surface immediately and now never surfaces: the session stays busy with no error and no way to know the prompt failed.

Suggestion: keep waiting for the parts that settle, but bound the wait (per-part timeout, or race the settled set against a deadline) so the original error is still reported when a sibling hangs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined for this PR, recorded as R6 on the review log.

The hang comes from LSP.documentSymbol having no timeout, and it is on main too. When no sibling part fails, Promise.all on main also waits for that symbol lookup, so a hung language server stalls every prompt with a ranged attachment whose range came back empty. This PR changes only the case where another part also fails: the error now waits for the hung part as well, instead of surfacing first. Bounding the lookup belongs on documentSymbol itself, which fixes both cases, and is its own change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor (Codex; reproduced by the lead). A failed prompt now waits for every sibling. If a ranged ?start=N&end=N file's LSP.documentSymbol never answers (no timeout, R6), the error is never reported.

Probe: a missing directory plus missing.txt?start=3&end=3, with the legacy LSP.documentSymbol spied to return a promise that never settles. Head was still pending after 8 s; base rejected in 537 ms.

Not a blocker: on main the same hung lookup already stalls every prompt with that attachment, and no loop starts. Safe in a follow-up: bound documentSymbol, or report the first rejection and clear the verdict once every part has settled.

// altimate_change end
input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => {
if (part.type === "file") {
// before checking the protocol we check if this is an mcp resource because it needs special handling
Expand Down Expand Up @@ -3580,7 +3601,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",
Expand Down
74 changes: 74 additions & 0 deletions packages/opencode/test/altimate/telemetry/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -264,6 +274,70 @@ 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)
// 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("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()
Expand Down
Loading
Loading