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/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,
},
});
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. */