diff --git a/apps/mcp-server/src/tools/claim-task.ts b/apps/mcp-server/src/tools/claim-task.ts index 8e2ea98..7abd56d 100644 --- a/apps/mcp-server/src/tools/claim-task.ts +++ b/apps/mcp-server/src/tools/claim-task.ts @@ -60,8 +60,39 @@ const claimTaskInputSchema = z.object({ .max(2_000) .optional() .describe("Short opener text attached to the run (e.g. session intent)."), + client: z + .string() + .min(1) + .max(50) + .optional() + .describe( + "Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.", + ), + model: z + .string() + .min(1) + .max(100) + .optional() + .describe( + "Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.", + ), }); +/** + * Build the metadata object stored on agent_runs at claim time. Returns + * `null` (not an empty object) when nothing identifying was passed so + * JSONB `metadata IS NULL` queries stay clean. + */ +function buildClaimMetadata(input: { + client?: string; + model?: string; +}): Record | null { + const meta: Record = {}; + if (input.client) meta.client = input.client; + if (input.model) meta.model = input.model; + return Object.keys(meta).length === 0 ? null : meta; +} + export function registerClaimTaskTool(mcp: McpServer): void { mcp.registerTool( "claim_task", @@ -112,6 +143,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { actorUserId: agentRuns.actorUserId, startedAt: agentRuns.startedAt, notes: agentRuns.notes, + metadata: agentRuns.metadata, }) .from(agentRuns) .where( @@ -131,10 +163,26 @@ export function registerClaimTaskTool(mcp: McpServer): void { } // Idempotent re-claim. Refresh `notes` if the caller provided // new ones; leave timing untouched (started_at is sacred). - if (input.notes && input.notes !== existing.notes) { + // For client/model, merge into existing metadata so a fresh + // claim from a different model (e.g. user switched models in + // Cursor mid-session) updates the attribution without losing + // prior keys. + const incomingMeta = buildClaimMetadata(input); + const mergedMeta = + incomingMeta === null + ? existing.metadata + : { ...(existing.metadata ?? {}), ...incomingMeta }; + const notesChanged = Boolean( + input.notes && input.notes !== existing.notes, + ); + const metaChanged = incomingMeta !== null; + if (notesChanged || metaChanged) { await db .update(agentRuns) - .set({ notes: input.notes }) + .set({ + ...(notesChanged ? { notes: input.notes! } : {}), + ...(metaChanged ? { metadata: mergedMeta } : {}), + }) .where(eq(agentRuns.id, existing.id)); } @@ -163,6 +211,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { const shouldTransition = priorStatus === "ready" || priorStatus === "draft"; + const claimMeta = buildClaimMetadata(input); const result = await db.transaction(async (tx) => { const [created] = await tx .insert(agentRuns) @@ -171,6 +220,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { backlogItemId: input.backlogItemId, actorUserId, notes: input.notes ?? null, + metadata: claimMeta, }) .returning(); @@ -191,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { backlog_item_id: input.backlogItemId, prior_status: priorStatus, transitioned: shouldTransition, + ...(claimMeta ?? {}), ...(actorUserId === null ? { system_actor: "mcp:claim_task" } : {}), }, }); diff --git a/apps/mcp-server/src/tools/complete-task.ts b/apps/mcp-server/src/tools/complete-task.ts index 9233ef1..f998658 100644 --- a/apps/mcp-server/src/tools/complete-task.ts +++ b/apps/mcp-server/src/tools/complete-task.ts @@ -26,6 +26,22 @@ const completeTaskInputSchema = z.object({ notes: z.string().max(2000).optional(), error: z.string().max(2000).optional(), finalStatus: finalStatusSchema.optional(), + client: z + .string() + .min(1) + .max(50) + .optional() + .describe( + "Late-bound client identifier — only needed if you didn't supply one at claim time, or if it changed mid-session. Merged into existing run metadata; existing keys take precedence unless overridden.", + ), + model: z + .string() + .min(1) + .max(100) + .optional() + .describe( + "Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.", + ), }); type CompleteTaskInput = z.infer; @@ -89,6 +105,19 @@ export function registerCompleteTaskTool(mcp: McpServer): void { const finalStatus = resolveFinalStatus(input); const finishedAt = new Date(); + // Build the merged metadata object once, outside the txn, so we + // can also stamp the same shape into the audit log without + // re-reading the row. Late-bound `client`/`model` from this call + // override whatever claim_task wrote; that's the right policy + // because the latest caller is the one we can verify. + const incomingMeta: Record = {}; + if (input.client) incomingMeta.client = input.client; + if (input.model) incomingMeta.model = input.model; + const hasIncomingMeta = Object.keys(incomingMeta).length > 0; + const mergedMeta = hasIncomingMeta + ? { ...(run.metadata ?? {}), ...incomingMeta } + : run.metadata; + await db.transaction(async (tx) => { await tx .update(agentRuns) @@ -100,6 +129,7 @@ export function registerCompleteTaskTool(mcp: McpServer): void { tokensTotal, notes: input.notes ?? null, error: input.error ?? null, + ...(hasIncomingMeta ? { metadata: mergedMeta } : {}), }) .where(eq(agentRuns.id, input.runId)); @@ -121,6 +151,7 @@ export function registerCompleteTaskTool(mcp: McpServer): void { finalStatus, backlogItemId: run.backlogItemId, tokensTotal, + ...(mergedMeta ?? {}), }, }); }); diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx index a2e426e..f23b2f2 100644 --- a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx @@ -99,6 +99,28 @@ function describeOutcome( return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP }; } +/** + * Read `client` / `model` out of a run's metadata blob. The column is + * JSONB so we're treating it as unstructured here — short-circuit on + * null and ignore values that aren't strings (defensive against + * hand-inserted rows from psql). + */ +function describeAgent(metadata: unknown): { + client: string | null; + model: string | null; + label: string | null; +} { + if (!metadata || typeof metadata !== "object") { + return { client: null, model: null, label: null }; + } + const m = metadata as Record; + const client = typeof m.client === "string" ? m.client : null; + const model = typeof m.model === "string" ? m.model : null; + if (!client && !model) return { client, model, label: null }; + if (client && model) return { client, model, label: `${client} · ${model}` }; + return { client, model, label: client ?? model }; +} + export default function RunsPage() { const params = useParams(); const workspaceSlug = params?.workspaceSlug as string | undefined; @@ -206,6 +228,7 @@ export default function RunsPage() { {runsQuery.data.rows.map((row) => { const { label, key } = describeOutcome(row.outcome, row.finishedAt); + const agent = describeAgent(row.metadata); const isOpen = expanded === row.id; const hasDetail = Boolean(row.notes || row.error); return ( @@ -246,13 +269,25 @@ export default function RunsPage() { {formatDuration(row.startedAt, row.finishedAt)} - {row.actorUserId ? ( - - {row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)} - - ) : ( - - )} +
+ {row.actorUserId ? ( + + {row.actorName ?? + row.actorEmail ?? + row.actorUserId.slice(0, 8)} + + ) : ( + + )} + {agent.label ? ( + + {agent.label} + + ) : null} +