Skip to content
Merged
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
22 changes: 16 additions & 6 deletions apps/desktop/src/renderer/components/panels/QueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */}
Expand All @@ -483,9 +488,9 @@ function TaskDetail({ taskId }: TaskDetailProps) {
{providerLabel(provider)}
</Badge>
)}
{env && (
<Badge variant="outline" className="text-xs">
{env.name}
{model && (
<Badge variant="outline" className="text-xs" title={model}>
{cloudModelLabel(model)}
</Badge>
)}
{repo && (
Expand Down Expand Up @@ -553,6 +558,11 @@ function TaskDetail({ taskId }: TaskDetailProps) {
{providerLabel(provider)}
</Badge>
)}
{model && (
<Badge variant="outline" title={model}>
{cloudModelLabel(model)}
</Badge>
)}
{(() => {
// Set when the task ran an agent skill (metadata.skill).
const skill = task.metadata?.skill as { name?: string } | undefined;
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/components/panels/QueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */}
Expand All @@ -493,9 +498,9 @@ function TaskDetail({ taskId }: TaskDetailProps) {
{providerLabel(provider)}
</Badge>
)}
{env && (
<Badge variant="outline" className="text-xs">
{env.name}
{model && (
<Badge variant="outline" className="text-xs" title={model}>
{cloudModelLabel(model)}
</Badge>
)}
{repo && (
Expand Down Expand Up @@ -563,6 +568,11 @@ function TaskDetail({ taskId }: TaskDetailProps) {
{providerLabel(provider)}
</Badge>
)}
{model && (
<Badge variant="outline" title={model}>
{cloudModelLabel(model)}
</Badge>
)}
{(() => {
// Set when the task ran an agent skill (metadata.skill).
const skill = task.metadata?.skill as { name?: string } | undefined;
Expand Down
87 changes: 87 additions & 0 deletions packages/backend/src/__tests__/dispatchedModel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 11 additions & 1 deletion packages/backend/src/__tests__/fleetCredentialCustody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -109,6 +110,7 @@ function task(over: Partial<Task> = {}): Task {
beforeEach(() => {
createSandbox.mockClear();
patchTaskMetadata.mockClear();
emitTaskUpdate.mockClear();
getSelfHostedCredentials.mockReset();
});

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/__tests__/posthogFailoverModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() },
});
});
});
4 changes: 3 additions & 1 deletion packages/backend/src/services/posthogCode/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/services/selfHosted/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/services/taskCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ async function redispatchCloudTask(
posthogStatus: null,
posthogLogUrl: null,
posthogPrUrl: null,
posthogModel: null,
},
});

Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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. */
Expand Down
Loading