Skip to content

Commit 3edc2ff

Browse files
authored
feat(task): add run-state divergence comparison and metrics (#2047)
* feat(task): add run-state divergence comparison and metrics * fix(task): key divergence dedupe on the event payload and test the schema * fix(task): sort event keys in the divergence key
1 parent a061271 commit 3edc2ff

9 files changed

Lines changed: 636 additions & 8 deletions

File tree

‎packages/types/src/__tests__/telemetry.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
ConsecutiveMistakeError,
1414
isConsecutiveMistakeError,
1515
extractConsecutiveMistakeErrorProperties,
16+
rooCodeTelemetryEventSchema,
17+
TelemetryEventName,
1618
} from "../telemetry.js"
1719

1820
describe("telemetry error utilities", () => {
@@ -575,3 +577,79 @@ describe("telemetry error utilities", () => {
575577
})
576578
})
577579
})
580+
581+
describe("rooCodeTelemetryEventSchema run-state events", () => {
582+
const appProperties = {
583+
appName: "zoo-code",
584+
appVersion: "1.0.0",
585+
vscodeVersion: "1.100.0",
586+
platform: "linux",
587+
editorName: "vscode",
588+
language: "en",
589+
mode: "code",
590+
}
591+
const runStateProperties = {
592+
...appProperties,
593+
taskId: "task-1",
594+
kind: "rejection",
595+
eventTag: "askStarted",
596+
mismatched: [],
597+
phase: "running",
598+
streamTag: "none",
599+
abort: true,
600+
}
601+
602+
it.each([TelemetryEventName.RUN_STATE_DIVERGENCE, TelemetryEventName.RUN_STATE_KNOWN_RACE])(
603+
"keeps the run-state properties of %s",
604+
(type) => {
605+
const result = rooCodeTelemetryEventSchema.safeParse({
606+
type,
607+
properties: { ...runStateProperties, raceId: "R-9", mismatched: ["abortReason"] },
608+
})
609+
610+
expect(result.success).toBe(true)
611+
expect(result.data?.properties).toMatchObject({
612+
taskId: "task-1",
613+
kind: "rejection",
614+
raceId: "R-9",
615+
eventTag: "askStarted",
616+
mismatched: ["abortReason"],
617+
phase: "running",
618+
streamTag: "none",
619+
abort: true,
620+
})
621+
},
622+
)
623+
624+
it("accepts an event without a race ID", () => {
625+
const result = rooCodeTelemetryEventSchema.safeParse({
626+
type: TelemetryEventName.RUN_STATE_DIVERGENCE,
627+
properties: runStateProperties,
628+
})
629+
630+
expect(result.success).toBe(true)
631+
})
632+
633+
it("rejects an unknown kind", () => {
634+
const result = rooCodeTelemetryEventSchema.safeParse({
635+
type: TelemetryEventName.RUN_STATE_DIVERGENCE,
636+
properties: { ...runStateProperties, kind: "other" },
637+
})
638+
639+
expect(result.success).toBe(false)
640+
})
641+
642+
it.each(["taskId", "eventTag", "mismatched", "phase", "streamTag", "abort"])(
643+
"rejects an event without %s",
644+
(property) => {
645+
const { [property]: _omitted, ...properties } = runStateProperties as Record<string, unknown>
646+
647+
const result = rooCodeTelemetryEventSchema.safeParse({
648+
type: TelemetryEventName.RUN_STATE_KNOWN_RACE,
649+
properties,
650+
})
651+
652+
expect(result.success).toBe(false)
653+
},
654+
)
655+
})

‎packages/types/src/telemetry.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export enum TelemetryEventName {
8686
TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed",
8787
MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response",
8888
READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used",
89+
RUN_STATE_DIVERGENCE = "Run State Divergence",
90+
RUN_STATE_KNOWN_RACE = "Run State Known Race",
8991
}
9092

9193
/**
@@ -221,6 +223,20 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
221223
]),
222224
properties: telemetryPropertiesSchema,
223225
}),
226+
z.object({
227+
type: z.enum([TelemetryEventName.RUN_STATE_DIVERGENCE, TelemetryEventName.RUN_STATE_KNOWN_RACE]),
228+
properties: z.object({
229+
...telemetryPropertiesSchema.shape,
230+
taskId: z.string(),
231+
kind: z.enum(["mismatch", "rejection"]),
232+
raceId: z.string().optional(),
233+
eventTag: z.string(),
234+
mismatched: z.array(z.string()),
235+
phase: z.string(),
236+
streamTag: z.string(),
237+
abort: z.boolean(),
238+
}),
239+
}),
224240
z.object({
225241
type: z.literal(TelemetryEventName.TELEMETRY_SETTINGS_CHANGED),
226242
properties: z.object({

‎src/__mocks__/vscode.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export const env = {
107107
openExternal: () => Promise.resolve(),
108108
}
109109

110+
export const ExtensionMode = { Production: 1, Development: 2, Test: 3 }
110111
export const Uri = mockUri
111112
export const Range = mockRange
112113
export const Position = mockPosition

‎src/core/task/Task.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ import { shouldAddUserMessageToHistory } from "./messageCounting"
145145
import { type TaskExecutionContext } from "./providerHandoff"
146146
import { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState"
147147
import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow"
148+
import {
149+
divergenceKey,
150+
findDivergences,
151+
isDivergenceCheckEnabled,
152+
reportDivergence,
153+
} from "./run-state/runStateDivergence"
148154

149155
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
150156
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@@ -408,6 +414,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
408414

409415
runState: RunState = initialRunState
410416
readonly runRejections: RunRejection[] = []
417+
private readonly divergenceCheckEnabled: boolean
418+
private readonly reportedDivergences = new Set<string>()
411419

412420
// API
413421
apiConfiguration: ProviderSettings
@@ -753,6 +761,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
753761
)
754762
this.providerRef = new WeakRef(provider)
755763
this.globalStoragePath = provider.context.globalStorageUri.fsPath
764+
this.divergenceCheckEnabled = isDivergenceCheckEnabled(Package.releaseChannel, provider.context.extensionMode)
756765
this.diffViewProvider = new DiffViewProvider(this.cwd, this)
757766
this.enableCheckpoints = enableCheckpoints
758767
this.checkpointTimeout = checkpointTimeout
@@ -3535,7 +3544,26 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35353544
}
35363545

35373546
private driveRunEvent(event: RunEvent): void {
3538-
this.runState = applyRunEvent(this.runState, event, this.runRejections)
3547+
const { state, rejected } = applyRunEvent(this.runState, event, this.runRejections)
3548+
this.runState = state
3549+
3550+
if (this.divergenceCheckEnabled) {
3551+
const fields = {
3552+
abort: this.abort,
3553+
abandoned: this.abandoned,
3554+
abortReason: this.abortReason,
3555+
isInitialized: this.isInitialized,
3556+
isStreaming: this.isStreaming,
3557+
didFinishAbortingStream: this.didFinishAbortingStream,
3558+
}
3559+
for (const divergence of findDivergences({ event, state, fields, rejected })) {
3560+
if (this.reportedDivergences.has(divergenceKey(divergence))) {
3561+
continue
3562+
}
3563+
this.reportedDivergences.add(divergenceKey(divergence))
3564+
reportDivergence(this.taskId, divergence)
3565+
}
3566+
}
35393567
}
35403568

35413569
public abortTask(isAbandoned = false): Promise<void> {

‎src/core/task/__tests__/Task.run-shadow.spec.ts‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// npx vitest run core/task/__tests__/Task.run-shadow.spec.ts
22

3+
import * as vscode from "vscode"
34
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
5+
import { TelemetryEventName } from "@roo-code/types"
46
import { TelemetryService } from "@roo-code/telemetry"
57

68
import { makeExtensionContext, makeUri } from "../../../test-utils/vscode"
@@ -11,10 +13,13 @@ import { initialRunState } from "../run-state/runState"
1113
vi.mock("../../../i18n", () => ({ t: (key: string) => key }))
1214
vi.mock("../../ignore/RooIgnoreController")
1315

14-
function makeTask(): Task {
16+
function makeTask(extensionMode = vscode.ExtensionMode.Production): Task {
1517
const provider = {
1618
log: vi.fn(),
17-
context: makeExtensionContext({ globalStorageUri: makeUri("/mock/global-storage") }),
19+
context: makeExtensionContext({
20+
globalStorageUri: makeUri("/mock/global-storage"),
21+
extensionMode,
22+
}),
1823
}
1924
// Double cast: the constructor reads only these provider members.
2025
return new Task({
@@ -129,3 +134,71 @@ describe("Task run-state write helpers", () => {
129134
expect(task.runRejections).toEqual([])
130135
})
131136
})
137+
138+
describe("Task run-state divergence report", () => {
139+
let warn: ReturnType<typeof vi.spyOn>
140+
let captureEvent: ReturnType<typeof vi.spyOn>
141+
142+
const divergenceMetrics = () =>
143+
captureEvent.mock.calls.filter(
144+
([name]: unknown[]) =>
145+
name === TelemetryEventName.RUN_STATE_DIVERGENCE || name === TelemetryEventName.RUN_STATE_KNOWN_RACE,
146+
)
147+
148+
const divergenceWarnings = () =>
149+
warn.mock.calls.filter(([message]: unknown[]) => message === "[RunState] divergence")
150+
151+
beforeEach(() => {
152+
if (!TelemetryService.hasInstance()) {
153+
TelemetryService.createInstance([])
154+
}
155+
warn = vi.spyOn(console, "warn").mockImplementation(() => {})
156+
captureEvent = vi.spyOn(TelemetryService.instance, "captureEvent").mockImplementation(() => {})
157+
})
158+
159+
afterEach(() => {
160+
vi.restoreAllMocks()
161+
})
162+
163+
it("logs and counts a rejected event once when the check is enabled", () => {
164+
const task = makeTask(vscode.ExtensionMode.Test)
165+
166+
task.requestAbandon()
167+
168+
expect(divergenceWarnings()).toHaveLength(1)
169+
expect(divergenceWarnings()[0][1]).toMatchObject({
170+
taskId: task.taskId,
171+
kind: "rejection",
172+
event: { tag: "abandonRequested" },
173+
state: initialRunState,
174+
mismatched: [],
175+
})
176+
expect(divergenceMetrics()).toEqual([
177+
[
178+
TelemetryEventName.RUN_STATE_DIVERGENCE,
179+
expect.objectContaining({ taskId: task.taskId, kind: "rejection", eventTag: "abandonRequested" }),
180+
],
181+
])
182+
})
183+
184+
it("reports a repeated rejection of the same event once per task", () => {
185+
const task = makeTask(vscode.ExtensionMode.Test)
186+
187+
task.requestAbandon()
188+
task.requestAbandon()
189+
190+
expect(task.runRejections).toHaveLength(2)
191+
expect(divergenceWarnings()).toHaveLength(1)
192+
expect(divergenceMetrics()).toHaveLength(1)
193+
})
194+
195+
it("stays silent for a production build on the stable channel", () => {
196+
const task = makeTask(vscode.ExtensionMode.Production)
197+
198+
task.requestAbandon()
199+
200+
expect(task.runRejections).toHaveLength(1)
201+
expect(divergenceWarnings()).toHaveLength(0)
202+
expect(divergenceMetrics()).toHaveLength(0)
203+
})
204+
})

0 commit comments

Comments
 (0)