From 24145d7a531018667b34b3906d8bfea25b5f3a1b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 19 Sep 2026 14:57:47 -0700 Subject: [PATCH 1/2] Record the model each cloud run dispatched with --- .../src/__tests__/fleetCredentialCustody.test.ts | 12 +++++++++++- .../src/__tests__/posthogFailoverModel.test.ts | 12 ++++++++++++ .../backend/src/services/posthogCode/executor.ts | 4 +++- packages/backend/src/services/selfHosted/executor.ts | 5 ++++- packages/backend/src/services/taskCreate.ts | 1 + 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/__tests__/fleetCredentialCustody.test.ts b/packages/backend/src/__tests__/fleetCredentialCustody.test.ts index e85afcea..26171248 100644 --- a/packages/backend/src/__tests__/fleetCredentialCustody.test.ts +++ b/packages/backend/src/__tests__/fleetCredentialCustody.test.ts @@ -67,7 +67,8 @@ vi.mock('../services/repoDefaultBranch.js', () => ({ const patchTaskMetadata = vi.fn(async (_id: string, _fn: unknown) => {}); vi.mock('../services/taskMetadataMutex.js', () => ({ patchTaskMetadata })); -vi.mock('../services/websocket.js', () => ({ emitTaskStatus: vi.fn() })); +const emitTaskUpdate = vi.fn(); +vi.mock('../services/websocket.js', () => ({ emitTaskStatus: vi.fn(), emitTaskUpdate })); const dbRows: unknown[] = [ { url: 'https://github.com/acme/widgets', name: 'widgets', defaultBranch: 'main' }, @@ -109,6 +110,7 @@ function task(over: Partial = {}): Task { beforeEach(() => { createSandbox.mockClear(); patchTaskMetadata.mockClear(); + emitTaskUpdate.mockClear(); getSelfHostedCredentials.mockReset(); }); @@ -255,6 +257,14 @@ describe('a fleet dispatch never lets the gateway supply the agent key', () => { expect(patched.cloudTask.extra.model).toBe('gpt-5.6-terra'); }); + it('tells clients the model, and nothing about where the run is hosted', async () => { + getSelfHostedCredentials.mockResolvedValue({ openaiKey: 'ey.codex.token' }); + await dispatchTaskToFleet(task({ metadata: { model: 'gpt-5.6-terra' } }), env); + expect(emitTaskUpdate).toHaveBeenCalledWith(expect.any(String), expect.any(String), { + metadata: { cloudTask: { provider: 'selfhosted', extra: { model: 'gpt-5.6-terra' } } }, + }); + }); + // A workspace that connected only Codex and never picked a model must not be // sent to the Claude default and then refused for a credential it was never // asked for — a dead end reached by doing nothing wrong. diff --git a/packages/backend/src/__tests__/posthogFailoverModel.test.ts b/packages/backend/src/__tests__/posthogFailoverModel.test.ts index eae65664..77a0059e 100644 --- a/packages/backend/src/__tests__/posthogFailoverModel.test.ts +++ b/packages/backend/src/__tests__/posthogFailoverModel.test.ts @@ -6,6 +6,7 @@ import type { Database } from '../db/client.js'; import { repositories, workspaces } from '../db/schema.js'; import { dispatchTaskToPostHogCode } from '../services/posthogCode/executor.js'; import { getPostHogCodeClient } from '../services/posthogCode/credentials.js'; +import { emitTaskUpdate } from '../services/websocket.js'; /** * What PostHog Code runs when a task FAILED OVER to it. @@ -124,4 +125,15 @@ describe('a task that failed over from the fleet', () => { await dispatchTaskToPostHogCode(task({}), env); expect(POSTHOG_IDS.has(dispatchedModel())).toBe(true); }); + + it.each([ + { name: 'a fleet-only pin that was replaced', metadata: { model: 'claude-fable-5-1' } }, + { name: 'a pin PostHog can run', metadata: { model: 'claude-sonnet-4-6' } }, + { name: 'no pin', metadata: {} }, + ])('tells clients the model that actually ran: $name', async ({ metadata }) => { + await dispatchTaskToPostHogCode(task(metadata), env); + expect(emitTaskUpdate).toHaveBeenCalledWith('ws1', 'task-1', { + metadata: { posthogModel: dispatchedModel() }, + }); + }); }); diff --git a/packages/backend/src/services/posthogCode/executor.ts b/packages/backend/src/services/posthogCode/executor.ts index 928356a3..1d185520 100644 --- a/packages/backend/src/services/posthogCode/executor.ts +++ b/packages/backend/src/services/posthogCode/executor.ts @@ -8,7 +8,7 @@ import { workspaces as workspacesTable, } from '../../db/schema.js'; import { patchTaskMetadata } from '../taskMetadataMutex.js'; -import { emitTaskStatus } from '../websocket.js'; +import { emitTaskStatus, emitTaskUpdate } from '../websocket.js'; import { getPostHogCodeClient, getPostHogCodeCredentials } from './credentials.js'; import { DEFAULT_POSTHOG_CODE_MODEL, PostHogCodeApiError } from './client.js'; @@ -155,7 +155,9 @@ export async function dispatchTaskToPostHogCode( posthogRunId: runId ?? existing.posthogRunId, posthogStatus: startedRun?.status ?? 'queued', posthogLogUrl: startedRun?.log_url ?? existing.posthogLogUrl, + posthogModel: model, })); + emitTaskUpdate(task.workspaceId, task.id, { metadata: { posthogModel: model } }); // Pin the env + flip to in_progress so the UI stops showing it as // queued. The poller takes it from here. diff --git a/packages/backend/src/services/selfHosted/executor.ts b/packages/backend/src/services/selfHosted/executor.ts index 38d4e399..afa8c8a0 100644 --- a/packages/backend/src/services/selfHosted/executor.ts +++ b/packages/backend/src/services/selfHosted/executor.ts @@ -19,7 +19,7 @@ import { workspaces as workspacesTable, } from '../../db/schema.js'; import { patchTaskMetadata } from '../taskMetadataMutex.js'; -import { emitTaskStatus } from '../websocket.js'; +import { emitTaskStatus, emitTaskUpdate } from '../websocket.js'; import { githubService } from '../github.js'; import { FleetCapacityError, @@ -370,6 +370,9 @@ export async function dispatchTaskToFleet(task: Task, env: Environment): Promise }, }; await patchTaskMetadata(task.id, (existing) => ({ ...existing, cloudTask })); + emitTaskUpdate(task.workspaceId, task.id, { + metadata: { cloudTask: { provider: cloudTask.provider, extra: { model } } }, + }); await getDbClient() .update(tasksTable) diff --git a/packages/backend/src/services/taskCreate.ts b/packages/backend/src/services/taskCreate.ts index df580cd5..fb3954be 100644 --- a/packages/backend/src/services/taskCreate.ts +++ b/packages/backend/src/services/taskCreate.ts @@ -389,6 +389,7 @@ async function redispatchCloudTask( posthogStatus: null, posthogLogUrl: null, posthogPrUrl: null, + posthogModel: null, }, }); From 6863d9647431ab064b585d6b8215887d4bb965d0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 19 Sep 2026 14:57:49 -0700 Subject: [PATCH 2/2] Show the run's model in the task header --- .../renderer/components/panels/QueuePanel.tsx | 22 +++-- apps/web/src/components/panels/QueuePanel.tsx | 22 +++-- .../src/__tests__/dispatchedModel.test.ts | 87 +++++++++++++++++++ packages/shared/src/index.ts | 21 +++++ 4 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 packages/backend/src/__tests__/dispatchedModel.test.ts diff --git a/apps/desktop/src/renderer/components/panels/QueuePanel.tsx b/apps/desktop/src/renderer/components/panels/QueuePanel.tsx index 0c32c388..109c32f5 100644 --- a/apps/desktop/src/renderer/components/panels/QueuePanel.tsx +++ b/apps/desktop/src/renderer/components/panels/QueuePanel.tsx @@ -35,7 +35,13 @@ import { prime, subscribePRStatus, } from '../../lib/prSummaryCache'; -import { isAgentTask, readCloudTaskMeta, TERMINAL_TASK_STATUSES } from '@talyn/shared'; +import { + cloudModelLabel, + isAgentTask, + readCloudTaskMeta, + readDispatchedModel, + TERMINAL_TASK_STATUSES, +} from '@talyn/shared'; import type { TaskScheduleError } from '@talyn/shared'; import type { Task, TaskStatus, TaskType, TaskPriority } from '@talyn/shared'; import { ProviderIcon, providerLabel, taskCloudProvider } from '../../lib/providerMeta'; @@ -368,6 +374,7 @@ function TaskDetail({ taskId }: TaskDetailProps) { const repo = task?.repositoryId ? repositories.find(r => r.id === task.repositoryId) : null; const cloudMeta = task ? readCloudTaskMeta(task) : null; const provider = task ? taskCloudProvider(task, environments) : null; + const model = task ? readDispatchedModel(task, provider) : null; // PR detail side-sheet — opened by clicking the PR status pill on // the task header. Stays mounted at the TaskDetail root so it @@ -457,8 +464,6 @@ function TaskDetail({ taskId }: TaskDetailProps) { // If task is running, show terminal if (isRunning) { - const env = environments.find((e) => e.id === task.assignedEnvironmentId); - return ( <> {/* Header */} @@ -483,9 +488,9 @@ function TaskDetail({ taskId }: TaskDetailProps) { {providerLabel(provider)} )} - {env && ( - - {env.name} + {model && ( + + {cloudModelLabel(model)} )} {repo && ( @@ -553,6 +558,11 @@ function TaskDetail({ taskId }: TaskDetailProps) { {providerLabel(provider)} )} + {model && ( + + {cloudModelLabel(model)} + + )} {(() => { // Set when the task ran an agent skill (metadata.skill). const skill = task.metadata?.skill as { name?: string } | undefined; diff --git a/apps/web/src/components/panels/QueuePanel.tsx b/apps/web/src/components/panels/QueuePanel.tsx index 8bb201a9..457e9460 100644 --- a/apps/web/src/components/panels/QueuePanel.tsx +++ b/apps/web/src/components/panels/QueuePanel.tsx @@ -34,7 +34,13 @@ import { prime, subscribePRStatus, } from '../../lib/prSummaryCache'; -import { isAgentTask, readCloudTaskMeta, TERMINAL_TASK_STATUSES } from '@talyn/shared'; +import { + cloudModelLabel, + isAgentTask, + readCloudTaskMeta, + readDispatchedModel, + TERMINAL_TASK_STATUSES, +} from '@talyn/shared'; import type { TaskScheduleError } from '@talyn/shared'; import type { Task, TaskStatus, TaskType, TaskPriority } from '@talyn/shared'; import { ProviderIcon, providerLabel, taskCloudProvider } from '../../lib/providerMeta'; @@ -378,6 +384,7 @@ function TaskDetail({ taskId }: TaskDetailProps) { const repo = task?.repositoryId ? repositories.find(r => r.id === task.repositoryId) : null; const cloudMeta = task ? readCloudTaskMeta(task) : null; const provider = task ? taskCloudProvider(task, environments) : null; + const model = task ? readDispatchedModel(task, provider) : null; // PR detail side-sheet — opened by clicking the PR status pill on // the task header. Stays mounted at the TaskDetail root so it @@ -467,8 +474,6 @@ function TaskDetail({ taskId }: TaskDetailProps) { // If task is running, show terminal if (isRunning) { - const env = environments.find((e) => e.id === task.assignedEnvironmentId); - return ( <> {/* Header */} @@ -493,9 +498,9 @@ function TaskDetail({ taskId }: TaskDetailProps) { {providerLabel(provider)} )} - {env && ( - - {env.name} + {model && ( + + {cloudModelLabel(model)} )} {repo && ( @@ -563,6 +568,11 @@ function TaskDetail({ taskId }: TaskDetailProps) { {providerLabel(provider)} )} + {model && ( + + {cloudModelLabel(model)} + + )} {(() => { // Set when the task ran an agent skill (metadata.skill). const skill = task.metadata?.skill as { name?: string } | undefined; diff --git a/packages/backend/src/__tests__/dispatchedModel.test.ts b/packages/backend/src/__tests__/dispatchedModel.test.ts new file mode 100644 index 00000000..0fd64a3a --- /dev/null +++ b/packages/backend/src/__tests__/dispatchedModel.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { cloudModelLabel, readDispatchedModel } from '@talyn/shared'; + +const fleetRun = { + provider: 'selfhosted', + remoteTaskId: 'sbx_1', + extra: { model: 'claude-sonnet-5' }, +}; + +describe('readDispatchedModel', () => { + it.each([ + { + name: 'a fleet run reads the model its dispatch recorded', + metadata: { cloudTask: fleetRun }, + provider: 'selfhosted', + expected: 'claude-sonnet-5', + }, + { + name: 'a PostHog Code run reads posthogModel', + metadata: { posthogTaskId: 't1', posthogModel: 'claude-opus-5' }, + provider: 'posthog_code', + expected: 'claude-opus-5', + }, + { + name: 'a PostHog Code run ignores a stale fleet cloudTask', + metadata: { cloudTask: fleetRun, posthogModel: 'claude-opus-5' }, + provider: 'posthog_code', + expected: 'claude-opus-5', + }, + { + name: 'a PostHog Code run with no recorded model ignores the fleet one', + metadata: { cloudTask: fleetRun }, + provider: 'posthog_code', + expected: null, + }, + { + name: 'a fleet run ignores a stale posthogModel', + metadata: { posthogModel: 'claude-opus-5' }, + provider: 'selfhosted', + expected: null, + }, + { + name: 'the creation pin is not what ran', + metadata: { model: 'claude-fable-5-1', posthogTaskId: 't1' }, + provider: 'posthog_code', + expected: null, + }, + { + name: 'a cleared posthogModel reads as none', + metadata: { posthogModel: null }, + provider: 'posthog_code', + expected: null, + }, + { + name: 'a blank model reads as none', + metadata: { cloudTask: { ...fleetRun, extra: { model: ' ' } } }, + provider: 'selfhosted', + expected: null, + }, + { + name: 'a task with no provider has no model', + metadata: { cloudTask: fleetRun }, + provider: null, + expected: null, + }, + { + name: 'a task with no metadata has no model', + metadata: null, + provider: 'selfhosted', + expected: null, + }, + ])('$name', ({ metadata, provider, expected }) => { + expect(readDispatchedModel({ metadata }, provider)).toBe(expected); + }); +}); + +describe('cloudModelLabel', () => { + it.each([ + { id: 'claude-sonnet-5', label: 'Sonnet 5' }, + { id: 'claude-fable-5-1', label: 'Fable 5.1' }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra' }, + { id: 'claude-opus-4-5', label: 'claude-opus-4-5' }, + { id: 'some-future-model', label: 'some-future-model' }, + ])('$id reads as $label', ({ id, label }) => { + expect(cloudModelLabel(id)).toBe(label); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8d18c489..328274e8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1682,6 +1682,27 @@ export function readCloudTaskMeta(task: { return null; } +// Never `metadata.model`: that is the pin the task was created with, and a fleet pin that fails over to PostHog Code runs on a different model. +export function readDispatchedModel( + task: { metadata?: Record | null }, + provider: AnyCloudProviderType | null | undefined, +): string | null { + const meta = task.metadata ?? {}; + const cloud = meta.cloudTask as CloudTaskMetadata | undefined; + const recorded = + provider === 'posthog_code' + ? meta.posthogModel + : cloud?.provider === provider + ? cloud?.extra?.model + : undefined; + return typeof recorded === 'string' && recorded.trim() ? recorded : null; +} + +export function cloudModelLabel(modelId: string): string { + const known = [...FLEET_MODELS, ...POSTHOG_CODE_MODELS].find((m) => m.id === modelId); + return known?.label ?? modelId; +} + export interface GenerateTaskMetadataRequest { prompt: string; /** Optional env hint for resolving the cloud provider. */