diff --git a/apps/daemon/src/app-version.ts b/apps/daemon/src/app-version.ts index 1478c3d4db3..f857f829b22 100644 --- a/apps/daemon/src/app-version.ts +++ b/apps/daemon/src/app-version.ts @@ -4,6 +4,7 @@ import { dirname, join, parse as parsePath } from 'node:path'; import { releaseChannelFromVersion } from '@open-design/release'; export const APP_VERSION_FALLBACK = '0.0.0'; +export const UNKNOWN_APP_VERSION = 'unknown'; // Keep this structurally aligned with `@open-design/contracts` AppVersionInfo. // Daemon cannot import the package root type directly yet because its NodeNext @@ -17,6 +18,26 @@ export interface AppVersionInfo { arch: string; } +export function normalizeTelemetryAppVersion(value: unknown): string | null { + const version = cleanString(value); + return version && version !== APP_VERSION_FALLBACK && version !== UNKNOWN_APP_VERSION + ? version + : null; +} + +export function normalizeTelemetryAppVersionInfo(value: unknown): AppVersionInfo | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Partial; + const version = normalizeTelemetryAppVersion(candidate.version); + const channel = cleanString(candidate.channel); + const platform = cleanString(candidate.platform); + const arch = cleanString(candidate.arch); + if (!version || !channel || !platform || !arch || typeof candidate.packaged !== 'boolean') { + return null; + } + return { version, channel, packaged: candidate.packaged, platform, arch }; +} + interface PackageMetadata { version?: unknown; } diff --git a/apps/daemon/src/observability/task-observation-rollout.ts b/apps/daemon/src/observability/task-observation-rollout.ts index a31ae9d748a..e642a2729e0 100644 --- a/apps/daemon/src/observability/task-observation-rollout.ts +++ b/apps/daemon/src/observability/task-observation-rollout.ts @@ -36,6 +36,7 @@ import { type RunTelemetryDeliveryStateV1, type RunTelemetryDeliveryResult, } from './delivery-state.js'; +import { normalizeTelemetryAppVersion } from '../app-version.js'; import { buildStructuredMainRunObservationV1 } from './main-run-observation.js'; import { getDetectedRuntimeVersions } from '../runtimes/detection.js'; import { OD_NEXT_RUNTIME_PATH_DESCRIPTORS } from '../runtimes/od-next-capability-gate.js'; @@ -108,6 +109,13 @@ interface TaskRunLike { langfuseCompletedAt?: number; telemetryDelivery?: RunTelemetryDeliveryStateV1; strategyRolloutDecision?: OdNextRolloutDecision | null; + appVersionInfo?: { + version: string; + channel: string; + packaged: boolean; + platform?: string; + arch?: string; + } | null; } type PersistedDeliveryStatus = @@ -206,6 +214,21 @@ function cleanContextValue(value: string | undefined): string | null { return CONTEXT_VALUE_RE.test(normalized) ? normalized : null; } +function runAppVersionInfoForTask( + task: StrategyTaskExecutionRecord, + options: CreateTaskObservationRolloutServiceOptions, +): { version: string; channel: string; packaged: boolean } | null { + for (const mapping of task.runs) { + const candidate = options.getRun(mapping.runId)?.appVersionInfo; + const version = normalizeTelemetryAppVersion(candidate?.version); + const channel = candidate?.channel?.trim(); + if (version && channel && typeof candidate?.packaged === 'boolean') { + return { version, channel, packaged: candidate.packaged }; + } + } + return null; +} + export function readTaskObservationRolloutConfig( env: NodeJS.ProcessEnv = process.env, ): TaskObservationRolloutConfig { @@ -1209,17 +1232,19 @@ export function createTaskObservationRolloutService( aggregate = await taskAggregate(task, options, telemetry); recordAggregate(task.taskExecutionId, aggregate); sink = effectiveSink(); + const appVersionInfo = runAppVersionInfoForTask(task, options) + ?? telemetry.appVersionInfo; exportContext = { environment: claim.row.environment, tag: claim.row.tag, ...(telemetry.installationId !== undefined ? { installationId: telemetry.installationId } : {}), - ...(telemetry.appVersionInfo + ...(appVersionInfo ? { - appVersion: telemetry.appVersionInfo.version, - appChannel: telemetry.appVersionInfo.channel, - packaged: telemetry.appVersionInfo.packaged, + appVersion: appVersionInfo.version, + appChannel: appVersionInfo.channel, + packaged: appVersionInfo.packaged, } : {}), clientType: task.runs diff --git a/apps/daemon/src/routes/telemetry.ts b/apps/daemon/src/routes/telemetry.ts index e8fb29becd8..04e6aa7a0da 100644 --- a/apps/daemon/src/routes/telemetry.ts +++ b/apps/daemon/src/routes/telemetry.ts @@ -13,7 +13,7 @@ import { } from '../analytics.js'; import type { AnalyticsContext } from '../analytics.js'; import type { readAppConfig, writeAppConfig } from '../app-config.js'; -import { readCurrentAppVersionInfo } from '../app-version.js'; +import { readCurrentAppVersionInfo, UNKNOWN_APP_VERSION } from '../app-version.js'; import { reportRunFeedbackFromDaemon } from '../langfuse-bridge.js'; import { observePendingInstallerApplyAttempts } from '../migration/index.js'; import { @@ -24,6 +24,7 @@ export interface DaemonTelemetry { analyticsService: ReturnType; disposeFatalHandlers: () => void; getCachedAppVersion: () => any; + resolveAppVersion: () => Promise; reportFeedback: (req: { runId: string; rating: 'positive' | 'negative'; @@ -216,7 +217,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou await analyticsService.capture({ eventName: body.event, context, - appVersion: cachedAppVersion?.version ?? '0.0.0', + appVersion: cachedAppVersion?.version ?? UNKNOWN_APP_VERSION, properties, insertId: body.eventId, }); @@ -236,7 +237,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou : {}; analyticsService.captureSafety({ eventName, - appVersion: cachedAppVersion?.version ?? '0.0.0', + appVersion: cachedAppVersion?.version ?? UNKNOWN_APP_VERSION, properties, }); res.json({ ok: true }); @@ -247,10 +248,10 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou getAppVersion: () => cachedAppVersion, }); - void (async () => { + const appVersionPromise = (async () => { try { cachedAppVersion = await readCurrentAppVersionInfo(); - await observePendingInstallerApplyAttempts({ + void observePendingInstallerApplyAttempts({ analytics: analyticsService, appVersion: cachedAppVersion.version, currentChannel: cachedAppVersion.channel, @@ -258,9 +259,13 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou dataRoot: dataDir, logger: console, namespace: process.env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace, + }).catch(() => { + // Update-apply telemetry must not delay daemon version readiness. }); + return cachedAppVersion; } catch { // Telemetry is best-effort; appVersion is omitted when unavailable. + return null; } })(); @@ -268,6 +273,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou analyticsService, disposeFatalHandlers, getCachedAppVersion: () => cachedAppVersion, + resolveAppVersion: () => appVersionPromise, reportFeedback: (req) => reportRunFeedbackFromDaemon({ dataDir, @@ -593,7 +599,7 @@ function installFatalTelemetryHandlers({ try { await analyticsService.captureSafety({ eventName, - appVersion: getAppVersion()?.version ?? '0.0.0', + appVersion: getAppVersion()?.version ?? UNKNOWN_APP_VERSION, properties, }); } catch { diff --git a/apps/daemon/src/runtimes/run-terminal-reconciliation.ts b/apps/daemon/src/runtimes/run-terminal-reconciliation.ts index 057b3a665b9..a299fa7cce2 100644 --- a/apps/daemon/src/runtimes/run-terminal-reconciliation.ts +++ b/apps/daemon/src/runtimes/run-terminal-reconciliation.ts @@ -29,6 +29,12 @@ import { type RunTelemetryDeliveryResult, type RunTelemetryDeliveryStateV1, } from '../observability/delivery-state.js'; +import { + normalizeTelemetryAppVersion, + normalizeTelemetryAppVersionInfo, + UNKNOWN_APP_VERSION, + type AppVersionInfo, +} from '../app-version.js'; const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']); const RECONCILED_STATUS_MESSAGE = 'Run terminal state reconciled after daemon restart.'; @@ -47,6 +53,7 @@ interface DurableRunState extends RestartRecoverableDurableRunState { conversationId: string | null; assistantMessageId: string | null; agentId: string | null; + appVersionInfo?: AppVersionInfo; cancelOrigin?: TrackingRunCancelOrigin | null; terminalTrigger?: TrackingRunTerminalTrigger | null; createdAt: number; @@ -82,7 +89,7 @@ interface AnalyticsLike { interface ReconciliationOptions { analytics: AnalyticsLike; - appVersion: string; + appVersion?: string; appVersionInfo?: unknown; db: Database.Database; reportLangfuse(args: Record): unknown | Promise; @@ -105,6 +112,21 @@ interface ReconciliationOptions { finalizeTerminalLocally?: (run: DurableRunState, status: string, terminalAt: number) => void; } +function appVersionForRun(state: DurableRunState, options: ReconciliationOptions): string { + return normalizeTelemetryAppVersionInfo(state.appVersionInfo)?.version + ?? normalizeTelemetryAppVersionInfo(options.appVersionInfo)?.version + ?? normalizeTelemetryAppVersion(options.appVersion) + ?? UNKNOWN_APP_VERSION; +} + +function appVersionInfoForRun( + state: DurableRunState, + options: ReconciliationOptions, +): AppVersionInfo | null { + return normalizeTelemetryAppVersionInfo(state.appVersionInfo) + ?? normalizeTelemetryAppVersionInfo(options.appVersionInfo); +} + export interface RunTerminalReconciliationResult { scanned: number; interrupted: number; @@ -473,7 +495,7 @@ export async function reconcileDurableRunTerminals( await Promise.resolve(options.analytics.capture({ eventName: 'run_finished', context: state.analyticsRecovery.context, - appVersion: options.appVersion, + appVersion: appVersionForRun(state, options), properties, insertId: `${state.analyticsRecovery.insertId}-finish`, })); @@ -564,7 +586,7 @@ export async function reconcileDurableRunTerminals( run: hydrateRun(state, events), persistedRunStatus: state.status, persistedEndedAt: state.updatedAt, - appVersion: options.appVersionInfo ?? null, + appVersion: appVersionInfoForRun(state, options), deliveryIdempotencyKey: state.telemetryDelivery.idempotencyKey, onDeliveryAttempt: () => { state.telemetryDelivery = recordRunTelemetryDeliveryAttempt( diff --git a/apps/daemon/src/runtimes/runs.ts b/apps/daemon/src/runtimes/runs.ts index d628d9cd715..b7bd54a95ef 100644 --- a/apps/daemon/src/runtimes/runs.ts +++ b/apps/daemon/src/runtimes/runs.ts @@ -30,6 +30,7 @@ import { finalizeRunTelemetryDelivery, recordRunTelemetryDeliveryAttempt, } from '../observability/delivery-state.js'; +import { normalizeTelemetryAppVersionInfo } from '../app-version.js'; export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']); @@ -527,6 +528,7 @@ function durableRunState(run) { ? { strategyRolloutDecision: run.strategyRolloutDecision } : {}), agentId: run.agentId, + ...(run.appVersionInfo ? { appVersionInfo: run.appVersionInfo } : {}), status: run.status, createdAt: run.createdAt, updatedAt: run.updatedAt, @@ -679,6 +681,9 @@ export function createChatRunService({ // durable but before the terminal SSE event is published, so local outbox // writes share the exact terminal timestamp without delaying on delivery. onTerminal = null, + // Snapshot the daemon version at Run creation so a later daemon version + // cannot rewrite this Run's terminal telemetry during restart recovery. + getAppVersionInfo = () => null, }) { const runs = new Map(); const runIdsByClientRequestId = new Map(); @@ -815,6 +820,13 @@ export function createChatRunService({ const create = (meta = {}) => { const now = Date.now(); const id = randomUUID(); + let appVersionInfo = null; + try { + appVersionInfo = normalizeTelemetryAppVersionInfo(getAppVersionInfo()); + } catch { + // Version attribution is best-effort; missing is explicit in the + // durable state instead of persisting a placeholder release number. + } const run = { id, projectId: typeof meta.projectId === 'string' && meta.projectId ? meta.projectId : null, @@ -832,6 +844,7 @@ export function createChatRunService({ ? meta.strategyRolloutDecision : null, agentId: typeof meta.agentId === 'string' && meta.agentId ? meta.agentId : null, + appVersionInfo, projectMetadata: meta.projectMetadata && typeof meta.projectMetadata === 'object' && !Array.isArray(meta.projectMetadata) ? meta.projectMetadata diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 646da8c1518..c0811ffcfa6 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -301,7 +301,11 @@ export { signDesktopImportToken, verifyDesktopImportToken, } from './desktop-auth.js'; -import { readCurrentAppVersionInfo } from './app-version.js'; +import { + normalizeTelemetryAppVersionInfo, + readCurrentAppVersionInfo, + UNKNOWN_APP_VERSION, +} from './app-version.js'; import { findSkillById, listSkills, @@ -7458,11 +7462,19 @@ export async function startServer({ readAppConfig, writeAppConfig, }); + const resolvedAppVersionInfo = normalizeTelemetryAppVersionInfo( + await telemetry.resolveAppVersion(), + ); + const currentAppVersionInfo = () => + normalizeTelemetryAppVersionInfo(telemetry.getCachedAppVersion()) + ?? resolvedAppVersionInfo; + const currentAppVersion = () => + currentAppVersionInfo()?.version ?? UNKNOWN_APP_VERSION; const { analyticsService } = telemetry; registerStrategyRolloutRoutes(app, { db, analytics: analyticsService, - getAppVersion: () => telemetry.getCachedAppVersion()?.version ?? '0.0.0', + getAppVersion: currentAppVersion, requireLocalDaemonRequest, // Uncaught on purpose: an operator asking which mode is in effect must get // an error when the config cannot be read, never `off` / `default`. @@ -7473,7 +7485,7 @@ export async function startServer({ db, analytics: analyticsService, analyticsContext: run.analyticsContext, - appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0', + appVersion: currentAppVersion(), mode, reasonCode, // A thunk, not a value: the latch is the safety action and must land @@ -7495,6 +7507,7 @@ export async function startServer({ createSseResponse, createSseErrorPayload, runsLogDir: path.join(RUNTIME_DATA_DIR, 'runs'), + getAppVersionInfo: currentAppVersionInfo, // Fold committed side effects into a truncation-proof per-run ledger as // each event is emitted, so the finalization verdict (retry safety gate, // artifact_count, close-status artifactProducedThisRun) does not depend on @@ -7519,7 +7532,7 @@ export async function startServer({ }, }), analytics: analyticsService, - getAppVersion: () => telemetry.getCachedAppVersion()?.version ?? '0.0.0', + getAppVersion: currentAppVersion, readAnalyticsContext, }; const internalRunCreation = createInternalRunCreationService({ @@ -7562,8 +7575,8 @@ export async function startServer({ // stays off the startup critical path. void reconcileDurableRunTerminals({ analytics: analyticsService, - appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0', - appVersionInfo: telemetry.getCachedAppVersion(), + appVersion: currentAppVersion(), + appVersionInfo: currentAppVersionInfo(), db, reportLangfuse: reportRunCompletedFromDaemon, finalizeTerminalLocally: createAmrTerminalReportFinalizer(amrTerminalReportOutbox), @@ -8240,7 +8253,7 @@ export async function startServer({ await analyticsService.capture({ eventName, context: analyticsContext, - appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0', + appVersion: currentAppVersion(), properties, insertId: newInsertId(), }); diff --git a/apps/daemon/tests/observability/task-observation-rollout.test.ts b/apps/daemon/tests/observability/task-observation-rollout.test.ts index 9e838fd6e68..84a6f061965 100644 --- a/apps/daemon/tests/observability/task-observation-rollout.test.ts +++ b/apps/daemon/tests/observability/task-observation-rollout.test.ts @@ -260,6 +260,7 @@ describe('task observation rollout', () => { function service(input: { mode: 'off' | 'observe' | 'send'; prefs?: { metrics: boolean; content: boolean; artifactManifest: boolean }; + appVersionInfo?: { version: string; channel: string; packaged: boolean }; readTelemetryError?: Error; env?: Record; fetchImpl?: typeof fetch; @@ -276,6 +277,7 @@ describe('task observation rollout', () => { return { prefs: input.prefs ?? { metrics: true, content: true, artifactManifest: false }, installationId: 'installation-fixture', + ...(input.appVersionInfo ? { appVersionInfo: input.appVersionInfo } : {}), }; }, env: { @@ -335,6 +337,46 @@ describe('task observation rollout', () => { `).run(); } + it('keeps the mapped run version when restart finalization replaces single-run telemetry', async () => { + const fetchImpl = vi.fn(async () => acceptedResponse()); + const startedVersion = { + version: '0.21.1', + channel: 'stable', + packaged: true, + }; + const restartedVersion = { + version: '0.22.0', + channel: 'stable', + packaged: true, + }; + const durableRun = { + ...syntheticRun(), + appVersionInfo: { ...startedVersion, platform: 'darwin', arch: 'arm64' }, + }; + + await expect(service({ + mode: 'send', + appVersionInfo: restartedVersion, + fetchImpl, + getRun: (runId) => runId === 'run-1' ? durableRun : null, + }).finalizeForRun('run-1')).resolves.toMatchObject({ action: 'sent' }); + + const batch = JSON.parse(String(fetchImpl.mock.calls[0]![1]!.body)).batch as Array<{ + type: string; + body: Record; + }>; + const trace = batch.find((event) => event.type === 'trace-create'); + expect(trace?.body).toMatchObject({ + release: startedVersion.version, + version: startedVersion.version, + metadata: { + appVersion: startedVersion.version, + appChannel: startedVersion.channel, + packaged: startedVersion.packaged, + }, + }); + }); + it('defaults unset/auto to send, fails invalid explicit mode closed, and reuses shared context', () => { expect(readTaskObservationRolloutConfig({ OD_TELEMETRY_ENV: 'production', diff --git a/apps/daemon/tests/runtimes/run-terminal-reconciliation.test.ts b/apps/daemon/tests/runtimes/run-terminal-reconciliation.test.ts index 2dcbf76c9a9..fbe040a22ec 100644 --- a/apps/daemon/tests/runtimes/run-terminal-reconciliation.test.ts +++ b/apps/daemon/tests/runtimes/run-terminal-reconciliation.test.ts @@ -6,6 +6,7 @@ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { reconcileDurableRunTerminals } from '../../src/runtimes/run-terminal-reconciliation.js'; +import { createChatRunService } from '../../src/runtimes/runs.js'; describe('durable run terminal reconciliation', () => { let tmpDir: string; @@ -31,6 +32,178 @@ describe('durable run terminal reconciliation', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it('keeps the app version that started an interrupted run after a newer daemon restarts', async () => { + const startedWithVersion = { + version: '0.21.1', + channel: 'stable', + packaged: true, + platform: 'darwin', + arch: 'arm64', + }; + const runs = createChatRunService({ + createSseResponse: () => ({ send: vi.fn(), end: vi.fn(), cleanup: vi.fn() }), + createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }), + getAppVersionInfo: () => startedWithVersion, + runsLogDir: tmpDir, + } as never); + const run = runs.create({ + projectId: 'p1', + conversationId: 'c1', + assistantMessageId: 'm1', + agentId: 'claude', + }); + runs.setAnalyticsRecovery(run, { + context: { + deviceId: 'device-1', + sessionId: 'session-1', + clientType: 'desktop', + locale: 'en', + }, + properties: { + page_name: 'chat_panel', + area: 'chat_panel', + project_id: 'p1', + conversation_id: 'c1', + run_id: run.id, + }, + insertId: 'run-created-version-a', + }); + run.status = 'running'; + runs.persistState(run); + + const statePath = path.join(tmpDir, run.id, 'state.json'); + expect(JSON.parse(fs.readFileSync(statePath, 'utf8'))).toMatchObject({ + appVersionInfo: startedWithVersion, + }); + + const capture = vi.fn(async () => undefined); + const reportLangfuse = vi.fn(async () => ({ + langfuse_expected: true, + langfuse_delivery_status: 'accepted' as const, + })); + await reconcileDurableRunTerminals({ + analytics: { capture }, + appVersion: '0.22.0', + appVersionInfo: { + version: '0.22.0', + channel: 'stable', + packaged: true, + platform: 'darwin', + arch: 'arm64', + }, + db, + reportLangfuse, + runsLogDir: tmpDir, + }); + + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ + eventName: 'run_finished', + appVersion: startedWithVersion.version, + })); + expect(reportLangfuse).toHaveBeenCalledWith(expect.objectContaining({ + appVersion: startedWithVersion, + })); + }); + + it('uses the resolved current version when a legacy durable run has no version snapshot', async () => { + const runId = 'run-legacy-version'; + const runDir = path.join(tmpDir, runId); + fs.mkdirSync(runDir, { recursive: true }); + fs.writeFileSync(path.join(runDir, 'state.json'), JSON.stringify({ + schemaVersion: 1, + id: runId, + projectId: 'p1', + conversationId: 'c1', + assistantMessageId: 'm1', + agentId: 'claude', + status: 'running', + createdAt: 1_000, + updatedAt: 2_000, + analyticsRecovery: { + context: {}, + properties: { run_id: runId }, + insertId: 'run-created-legacy-version', + }, + })); + const currentVersion = { + version: '0.22.0', + channel: 'stable', + packaged: true, + platform: 'darwin', + arch: 'arm64', + }; + const capture = vi.fn(async () => undefined); + const reportLangfuse = vi.fn(async () => ({ + langfuse_expected: true, + langfuse_delivery_status: 'accepted' as const, + })); + + await reconcileDurableRunTerminals({ + analytics: { capture }, + appVersion: currentVersion.version, + appVersionInfo: currentVersion, + db, + reportLangfuse, + runsLogDir: tmpDir, + }); + + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ + appVersion: currentVersion.version, + })); + expect(reportLangfuse).toHaveBeenCalledWith(expect.objectContaining({ + appVersion: currentVersion, + })); + }); + + it('uses explicit unknown or missing semantics when no real version can be resolved', async () => { + const runId = 'run-version-unknown'; + const runDir = path.join(tmpDir, runId); + fs.mkdirSync(runDir, { recursive: true }); + fs.writeFileSync(path.join(runDir, 'state.json'), JSON.stringify({ + schemaVersion: 1, + id: runId, + projectId: 'p1', + conversationId: 'c1', + assistantMessageId: 'm1', + agentId: 'claude', + status: 'running', + createdAt: 1_000, + updatedAt: 2_000, + analyticsRecovery: { + context: {}, + properties: { run_id: runId }, + insertId: 'run-created-version-unknown', + }, + })); + const capture = vi.fn(async () => undefined); + const reportLangfuse = vi.fn(async () => ({ + langfuse_expected: true, + langfuse_delivery_status: 'accepted' as const, + })); + + await reconcileDurableRunTerminals({ + analytics: { capture }, + appVersion: '0.0.0', + appVersionInfo: { + version: '0.0.0', + channel: 'development', + packaged: false, + platform: 'darwin', + arch: 'arm64', + }, + db, + reportLangfuse, + runsLogDir: tmpDir, + }); + + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ + appVersion: 'unknown', + })); + expect(reportLangfuse).toHaveBeenCalledWith(expect.objectContaining({ + appVersion: null, + })); + }); + it('fails an interrupted run, repairs its message, and emits missing terminal telemetry once', async () => { const runId = 'run-interrupted'; const runDir = path.join(tmpDir, runId);