From d5eebf02e2382586bbf89ed058b3250777e82611 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 04:10:27 -0700 Subject: [PATCH] fix(workboard): record resolved runtime metadata instead of hardcoded codex engine (#108887) * fix(workboard): record resolved runtime metadata instead of hardcoded codex engine Workboard executions labeled every dispatched run engine=codex, model=default, and id suffix :codex even for Claude/other harness agents. The gateway agent admission phase now returns the resolved {harness, provider, model} for plugin subagent runs; the dispatcher records it verbatim and omits engine/model when unresolved. Engine becomes an open runtime identifier in the workboard contract (built-in launch choices stay a closed list), store/UI normalizers preserve historical labels as written instead of inventing codex, and new execution ids use an :agent-session suffix. Fixes #108362 * fix(workboard): accept undefined engine in ui engineModel helper --- extensions/workboard/src/dispatcher.test.ts | 55 +++++++++++++++++ extensions/workboard/src/dispatcher.ts | 16 ++--- extensions/workboard/src/sqlite-store.ts | 6 +- .../workboard/src/store-card-helpers.ts | 4 +- extensions/workboard/src/store-normalizers.ts | 48 +++++++-------- extensions/workboard/src/store.test.ts | 59 +++++++++++++++++++ packages/workboard-contract/src/index.ts | 7 ++- .../agent-run-admission-phase.ts | 39 +++++++++--- .../agent.sessions-and-models.test-utils.ts | 28 ++++++++- src/gateway/server-plugins.test.ts | 30 ++++++++++ src/gateway/server-plugins.ts | 18 +++++- src/plugins/runtime/types.ts | 5 ++ ui/src/lib/workboard/execution.ts | 18 ++++-- ui/src/lib/workboard/index.test.ts | 52 ++++++++++++++-- .../lib/workboard/metadata-normalization.ts | 17 ++---- ui/src/pages/workboard/view.ts | 4 +- 16 files changed, 333 insertions(+), 73 deletions(-) diff --git a/extensions/workboard/src/dispatcher.test.ts b/extensions/workboard/src/dispatcher.test.ts index d9118adc87e..996ae0cfcaf 100644 --- a/extensions/workboard/src/dispatcher.test.ts +++ b/extensions/workboard/src/dispatcher.test.ts @@ -24,6 +24,61 @@ function createMemoryStore(): WorkboardKeyedStore } describe("dispatchAndStartWorkboardCards", () => { + it("persists the resolved subagent runtime on new executions", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Claude worker", + status: "ready", + workspaceAccess: { unrestricted: true }, + }); + const run = vi.fn().mockResolvedValue({ + runId: "run-claude", + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }); + + await dispatchAndStartWorkboardCards({ + store, + subagent: { run }, + options: { now: 10, maxStarts: 1 }, + }); + + await expect(store.get(card.id)).resolves.toMatchObject({ + execution: { + id: `${card.id}:agent-session`, + engine: "claude-cli", + model: "anthropic/claude-sonnet-4-6", + runId: "run-claude", + }, + }); + }); + + it("omits unresolved runtime metadata instead of labeling it codex", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Unknown runtime worker", + status: "ready", + workspaceAccess: { unrestricted: true }, + }); + + await dispatchAndStartWorkboardCards({ + store, + subagent: { run: vi.fn().mockResolvedValue({ runId: "run-unknown" }) }, + options: { now: 10, maxStarts: 1 }, + }); + + const execution = (await store.get(card.id))?.execution; + expect(execution).toMatchObject({ + id: `${card.id}:agent-session`, + runId: "run-unknown", + }); + expect(execution).not.toHaveProperty("engine"); + expect(execution).not.toHaveProperty("model"); + }); + it("materializes managed worktrees, supplies cwd, persists them, and cleans up on run end", async () => { const store = new WorkboardStore(createMemoryStore()); const card = await store.create({ diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 414188b94c4..26ea8124ec1 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -24,7 +24,6 @@ import { const DEFAULT_DISPATCH_MAX_STARTS = 3; const DEFAULT_DISPATCH_OWNER = "workboard-dispatcher"; -const DEFAULT_DISPATCH_MODEL = "default"; export type WorkboardSubagentRuntime = Pick; export type WorkboardWorktreeRuntime = PluginRuntime["worktrees"]; @@ -94,16 +93,20 @@ function buildExecution(params: { card: WorkboardCard; sessionKey: string; runId: string; - model: string; + runtime: Awaited>["runtime"]; now: number; }): WorkboardExecution { return { - id: params.card.execution?.id ?? `${params.card.id}:codex`, + id: params.card.execution?.id ?? `${params.card.id}:agent-session`, kind: "agent-session", - engine: "codex", mode: "autonomous", status: "running", - model: params.model, + ...(params.runtime + ? { + engine: params.runtime.harness, + model: `${params.runtime.provider}/${params.runtime.model}`, + } + : {}), sessionKey: params.sessionKey, runId: params.runId, startedAt: params.now, @@ -272,7 +275,6 @@ export async function dispatchAndStartWorkboardCards(params: { ); const started: WorkboardStartedRun[] = []; const startFailures: WorkboardStartFailure[] = []; - const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL; const cards = await params.store.list(); const candidates = await params.store.list({ boardId }); @@ -430,7 +432,7 @@ export async function dispatchAndStartWorkboardCards(params: { card: claimed.card, sessionKey, runId: run.runId, - model, + runtime: run.runtime, now, }), ...(materializedWorkspace ? { workspace: materializedWorkspace } : {}), diff --git a/extensions/workboard/src/sqlite-store.ts b/extensions/workboard/src/sqlite-store.ts index 69384db1348..39520e639c8 100644 --- a/extensions/workboard/src/sqlite-store.ts +++ b/extensions/workboard/src/sqlite-store.ts @@ -472,10 +472,12 @@ function readExecution(row: Row): WorkboardExecution | undefined { return { id, kind: "agent-session", - engine: requiredString(row, "execution_engine") as WorkboardExecution["engine"], mode: requiredString(row, "execution_mode") as WorkboardExecution["mode"], status: requiredString(row, "execution_status") as WorkboardExecution["status"], - model: requiredString(row, "execution_model"), + ...(stringValue(row, "execution_engine") + ? { engine: stringValue(row, "execution_engine") } + : {}), + ...(stringValue(row, "execution_model") ? { model: stringValue(row, "execution_model") } : {}), ...(stringValue(row, "execution_session_key") ? { sessionKey: stringValue(row, "execution_session_key") } : {}), diff --git a/extensions/workboard/src/store-card-helpers.ts b/extensions/workboard/src/store-card-helpers.ts index ea995b4c8b0..3fc693dd480 100644 --- a/extensions/workboard/src/store-card-helpers.ts +++ b/extensions/workboard/src/store-card-helpers.ts @@ -84,9 +84,9 @@ export function syncExecutionAttemptMetadata( id: existingAttempt?.id ?? key, status: attemptStatus, startedAt: existingAttempt?.startedAt ?? execution.startedAt, - engine: execution.engine, mode: execution.mode, - model: execution.model, + ...(execution.engine ? { engine: execution.engine } : {}), + ...(execution.model ? { model: execution.model } : {}), ...(execution.sessionKey ? { sessionKey: execution.sessionKey } : {}), ...(execution.runId ? { runId: execution.runId } : {}), ...(attemptStatus !== "running" && { endedAt: execution.updatedAt || now }), diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 2ff38c7d6aa..e909de3eade 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -5,7 +5,6 @@ import { WORKBOARD_DIAGNOSTIC_KINDS, WORKBOARD_DIAGNOSTIC_SEVERITIES, WORKBOARD_EVENT_KINDS, - WORKBOARD_EXECUTION_ENGINES, WORKBOARD_EXECUTION_MODES, WORKBOARD_EXECUTION_STATUSES, WORKBOARD_LINK_TYPES, @@ -28,7 +27,6 @@ import { type WorkboardEvent, type WorkboardEventKind, type WorkboardExecution, - type WorkboardExecutionEngine, type WorkboardExecutionMode, type WorkboardExecutionStatus, type WorkboardLink, @@ -486,19 +484,6 @@ export function deriveChildIdempotencyKey( return key.length <= 160 ? key : undefined; } -function normalizeExecutionEngine( - value: unknown, - fallback: WorkboardExecutionEngine, -): WorkboardExecutionEngine { - if ( - typeof value === "string" && - WORKBOARD_EXECUTION_ENGINES.includes(value as WorkboardExecutionEngine) - ) { - return value as WorkboardExecutionEngine; - } - return fallback; -} - function normalizeExecutionMode( value: unknown, fallback: WorkboardExecutionMode, @@ -630,16 +615,14 @@ function normalizeAttempt(value: unknown): WorkboardRunAttempt | null { const sessionKey = normalizeOptionalString(record.sessionKey); const runId = normalizeOptionalString(record.runId); const error = normalizeBoundedString(record.error, undefined, 800, "attempt error"); + const engine = normalizeBoundedString(record.engine, undefined, 160, "attempt engine"); const model = normalizeBoundedString(record.model, undefined, 160, "attempt model"); return { id, status: normalizeAttemptStatus(record.status, "running"), startedAt, ...(endedAt ? { endedAt } : {}), - ...(typeof record.engine === "string" && - WORKBOARD_EXECUTION_ENGINES.includes(record.engine as WorkboardExecutionEngine) - ? { engine: record.engine as WorkboardExecutionEngine } - : {}), + ...(engine ? { engine } : {}), ...(typeof record.mode === "string" && WORKBOARD_EXECUTION_MODES.includes(record.mode as WorkboardExecutionMode) ? { mode: record.mode as WorkboardExecutionMode } @@ -1128,24 +1111,27 @@ export function normalizeExecution(value: unknown): WorkboardExecution | undefin } const record = value as Record; const now = Date.now(); - const model = normalizeOptionalString(record.model); - const id = normalizeOptionalString(record.id) ?? randomUUID(); - if (!model) { - return undefined; - } - const startedAt = normalizeTimestamp(record.startedAt, now); - const updatedAt = normalizeTimestamp(record.updatedAt, startedAt); + // Preserve historical labels as written; old hardcoded "codex" rows cannot be inferred safely. + const engine = normalizeBoundedString(record.engine, undefined, 160, "execution engine"); + const model = normalizeBoundedString(record.model, undefined, 160, "execution model"); + const normalizedId = normalizeOptionalString(record.id); const sessionKey = normalizeOptionalString(record.sessionKey); const runId = normalizeOptionalString(record.runId); + if (!normalizedId && !engine && !model && !sessionKey && !runId) { + return undefined; + } + const id = normalizedId ?? randomUUID(); + const startedAt = normalizeTimestamp(record.startedAt, now); + const updatedAt = normalizeTimestamp(record.updatedAt, startedAt); return { id, kind: "agent-session", - engine: normalizeExecutionEngine(record.engine, "codex"), mode: normalizeExecutionMode(record.mode, "autonomous"), status: normalizeExecutionStatus(record.status, "idle"), - model, startedAt, updatedAt, + ...(engine ? { engine } : {}), + ...(model ? { model } : {}), ...(sessionKey ? { sessionKey } : {}), ...(runId ? { runId } : {}), }; @@ -1167,6 +1153,12 @@ export function syncExecutionSessionKey( function removeUndefinedExecutionFields(execution: WorkboardExecution): WorkboardExecution { const next = { ...execution }; + if (next.engine === undefined) { + delete next.engine; + } + if (next.model === undefined) { + delete next.model; + } if (next.sessionKey === undefined) { delete next.sessionKey; } diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index efca65a35a3..2fa8615fc59 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -13,6 +13,7 @@ import type { WorkboardKeyedStore, } from "./persistence-types.js"; import { createWorkboardSqliteStores } from "./sqlite-store.js"; +import { normalizeExecution } from "./store-normalizers.js"; import { WorkboardStore } from "./store.js"; function createMemoryStore(options?: { @@ -163,6 +164,18 @@ describe("WorkboardStore", () => { updatedAt: 2, }, }); + const unresolvedRuntimeCard = await store.create({ + title: "Persist unresolved runtime", + boardId: board.id, + execution: { + id: "exec-unresolved", + kind: "agent-session", + mode: "autonomous", + status: "running", + startedAt: 3, + updatedAt: 4, + }, + }); await store.addComment(card.id, { body: "round trip" }); const attached = await store.addAttachment(card.id, { fileName: "proof.txt", @@ -241,6 +254,14 @@ describe("WorkboardStore", () => { ]), }, }); + const reopenedUnresolvedRuntimeCard = await reopened.get(unresolvedRuntimeCard.id); + expect(reopenedUnresolvedRuntimeCard?.execution).toMatchObject({ + id: "exec-unresolved", + mode: "autonomous", + status: "running", + }); + expect(reopenedUnresolvedRuntimeCard?.execution).not.toHaveProperty("engine"); + expect(reopenedUnresolvedRuntimeCard?.execution).not.toHaveProperty("model"); expect(await reopened.getAttachment(attachmentId ?? "")).toMatchObject({ contentBase64: Buffer.from("ok").toString("base64"), }); @@ -368,6 +389,44 @@ describe("WorkboardStore", () => { expect(Object.hasOwn(entry?.card ?? {}, "metadata")).toBe(false); }); + it("preserves open execution engine identifiers without rewriting historical labels", async () => { + const store = new WorkboardStore(createMemoryStore()); + const runtimeCard = await store.create({ + title: "Runtime identity", + execution: { + id: "exec-runtime", + kind: "agent-session", + engine: "claude-cli", + mode: "autonomous", + status: "running", + model: "anthropic/claude-sonnet-4-6", + startedAt: 1, + updatedAt: 1, + }, + }); + const historicalCard = await store.create({ + title: "Historical identity", + execution: { + id: "exec-historical", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: "default", + startedAt: 1, + updatedAt: 1, + }, + }); + + expect(runtimeCard.execution?.engine).toBe("claude-cli"); + expect(runtimeCard.metadata?.attempts?.[0]?.engine).toBe("claude-cli"); + expect(historicalCard.execution?.engine).toBe("codex"); + }); + + it("rejects empty execution records instead of fabricating lifecycle state", () => { + expect(normalizeExecution({})).toBeUndefined(); + }); + it("preserves explicit zero positions", async () => { const store = new WorkboardStore(createMemoryStore()); diff --git a/packages/workboard-contract/src/index.ts b/packages/workboard-contract/src/index.ts index d79607b1e3d..f33f799d2f0 100644 --- a/packages/workboard-contract/src/index.ts +++ b/packages/workboard-contract/src/index.ts @@ -12,6 +12,7 @@ export const WORKBOARD_STATUSES = [ ] as const; export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const; +/** Built-in launch choices. Persisted execution engines remain an open runtime identifier. */ export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const; export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const; export const WORKBOARD_EXECUTION_STATUSES = [ @@ -81,7 +82,7 @@ export function isValidWorkboardBoardId(value: unknown): value is string { export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number]; export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number]; -export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number]; +export type WorkboardExecutionEngine = string; export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number]; export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number]; export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number]; @@ -96,10 +97,10 @@ export type WorkboardNotificationKind = (typeof WORKBOARD_NOTIFICATION_KINDS)[nu export type WorkboardExecution = { id: string; kind: "agent-session"; - engine: WorkboardExecutionEngine; + engine?: WorkboardExecutionEngine; mode: WorkboardExecutionMode; status: WorkboardExecutionStatus; - model: string; + model?: string; sessionKey?: string; runId?: string; startedAt: number; diff --git a/src/gateway/server-methods/agent-run-admission-phase.ts b/src/gateway/server-methods/agent-run-admission-phase.ts index 29ace9fed01..91e46bd08ed 100644 --- a/src/gateway/server-methods/agent-run-admission-phase.ts +++ b/src/gateway/server-methods/agent-run-admission-phase.ts @@ -5,7 +5,9 @@ import { isEmbeddedAgentRunAbortableForRunId, retainEmbeddedAgentRunAbortabilityForRunId, } from "../../agents/embedded-agent-runner/runs.js"; +import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; +import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -137,13 +139,35 @@ export async function prepareAgentRunDispatch(params: { : params.request.thinking; const effectiveAllowModelOverride = params.allowModelOverride || params.restoredCronContinuation !== undefined; - const activeModelProvider = - effectiveProviderOverride ?? - resolveSessionModelRef( - params.cfgForAgent ?? params.cfg, - params.sessionEntry, - params.activeSessionAgentId, - ).provider; + const runtimeConfig = params.cfgForAgent ?? params.cfg; + const sessionModel = resolveSessionModelRef( + runtimeConfig, + params.sessionEntry, + params.activeSessionAgentId, + ); + const activeModel = effectiveModelOverride + ? (resolvePersistedOverrideModelRef({ + defaultProvider: effectiveProviderOverride ?? sessionModel.provider, + overrideProvider: effectiveProviderOverride, + overrideModel: effectiveModelOverride, + }) ?? sessionModel) + : { + provider: effectiveProviderOverride ?? sessionModel.provider, + model: sessionModel.model, + }; + const resolvedRuntime = { + harness: resolveEffectiveAgentRuntime({ + cfg: runtimeConfig, + provider: activeModel.provider, + modelId: activeModel.model, + agentId: params.activeSessionAgentId, + sessionKey: params.resolvedSessionKey, + sessionEntry: params.sessionEntry, + }), + provider: activeModel.provider, + model: activeModel.model, + }; + const activeModelProvider = activeModel.provider; const lifecycleStorePath = params.resolvedSessionKey ? loadSessionEntry(params.resolvedSessionKey, { ...(params.activeSessionAgentId ? { agentId: params.activeSessionAgentId } : {}), @@ -282,6 +306,7 @@ export async function prepareAgentRunDispatch(params: { ...(params.resolvedSessionKey === "global" ? { agentId: params.activeSessionAgentId } : {}), status: "accepted" as const, acceptedAt: Date.now(), + ...(taskTrackingMode === "plugin_subagent" ? { runtime: resolvedRuntime } : {}), }; params.markAgentRunAccepted(true); setGatewayDedupeEntries({ diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index f960fad4376..1a21579ef73 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -120,7 +120,15 @@ describe("gateway agent handler", () => { const childSessionKey = "agent:work:subagent:plugin-helper"; const cfg = { session: { mainKey: "main", scope: "per-sender" }, - agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + list: [{ id: "main", default: true }, { id: "work" }], + }, }; mocks.listAgentIds.mockReturnValue(["main", "work"]); mocks.loadConfigReturn = cfg; @@ -157,7 +165,7 @@ describe("gateway agent handler", () => { }, }; - await invokeAgent( + const respond = await invokeAgent( { message: "background plugin subagent task", sessionKey: childSessionKey, @@ -170,6 +178,22 @@ describe("gateway agent handler", () => { }, ); + const acceptedPayload = respond.mock.calls.find( + ([ok, payload]) => + ok === true && + typeof payload === "object" && + payload !== null && + "status" in payload && + payload.status === "accepted", + )?.[1]; + expect(acceptedPayload).toMatchObject({ + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }); + await waitForAssertion(() => { const tasks = listTaskRecords().filter((task) => task.runId === runId); expect(tasks).toHaveLength(1); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 4fa02612597..03d43ab8072 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1278,6 +1278,36 @@ describe("loadGatewayPlugins", () => { expect(params.deliver).toBe(false); }); + test("returns resolved runtime metadata from plugin-owned subagent starts", async () => { + const runtime = await createSubagentRuntime(serverPluginsModule); + serverPluginsModule.setFallbackGatewayContext(createTestContext("resolved-subagent-runtime")); + handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { + expect(opts.req.method).toBe("agent"); + opts.respond(true, { + runId: "run-claude", + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }); + }); + + await expect( + runtime.run({ + sessionKey: "s-runtime", + message: "use configured runtime", + }), + ).resolves.toEqual({ + runId: "run-claude", + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }); + }); + test("forwards caller-supplied idempotencyKey on subagent run", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index cab2d4a3e5b..ee70a18f3e9 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -348,6 +348,19 @@ export async function dispatchTrustedPluginGatewayMethod( const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000; +function normalizeSubagentRunRuntime( + value: unknown, +): Awaited>["runtime"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const harness = typeof record.harness === "string" ? record.harness.trim() : ""; + const provider = typeof record.provider === "string" ? record.provider.trim() : ""; + const model = typeof record.model === "string" ? record.model.trim() : ""; + return harness && provider && model ? { harness, provider, model } : undefined; +} + export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => { const limit = @@ -394,7 +407,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { if (overrideRequested && !allowOverride) { throw new Error("provider/model override is not authorized for this plugin subagent run."); } - const payload = await dispatchGatewayMethod<{ runId?: string }>( + const payload = await dispatchGatewayMethod<{ runId?: string; runtime?: unknown }>( "agent", { sessionKey: params.sessionKey, @@ -423,7 +436,8 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { if (typeof runId !== "string" || !runId) { throw new Error("Gateway agent method returned an invalid runId."); } - return { runId }; + const runtime = normalizeSubagentRunRuntime(payload?.runtime); + return { runId, ...(runtime ? { runtime } : {}) }; }, async waitForRun(params) { const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>( diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 8f62e35c1ff..f650f7da328 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -35,6 +35,11 @@ type PluginManagedWorktree = { type SubagentRunResult = { runId: string; + runtime?: { + harness: string; + provider: string; + model: string; + }; }; type SubagentWaitParams = { diff --git a/ui/src/lib/workboard/execution.ts b/ui/src/lib/workboard/execution.ts index 247b6abd996..ad6961ebc66 100644 --- a/ui/src/lib/workboard/execution.ts +++ b/ui/src/lib/workboard/execution.ts @@ -37,6 +37,14 @@ const WORKBOARD_ENGINE_MODELS = { } as const; const WORKBOARD_SESSION_LABEL_MAX_CHARS = 512; +function engineModel(engine: WorkboardExecutionEngine | null | undefined): string | undefined { + return engine === "codex" + ? WORKBOARD_ENGINE_MODELS.codex + : engine === "claude" + ? WORKBOARD_ENGINE_MODELS.claude + : undefined; +} + function buildCardPrompt(card: WorkboardCard): string { const lines = [`Work on this OpenClaw Workboard card: ${card.title}`]; if (card.notes?.trim()) { @@ -116,15 +124,16 @@ function buildWorkboardExecution(params: { status: WorkboardExecutionStatus; }): WorkboardExecution { const now = Date.now(); + const model = engineModel(params.engine); return { - id: params.card.execution?.id ?? `${params.card.id}:${params.engine}`, + id: params.card.execution?.id ?? `${params.card.id}:agent-session`, kind: "agent-session", engine: params.engine, mode: params.mode, status: params.status, - model: WORKBOARD_ENGINE_MODELS[params.engine], startedAt: now, updatedAt: now, + ...(model ? { model } : {}), ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), ...(params.runId ? { runId: params.runId } : {}), }; @@ -228,6 +237,7 @@ export async function startWorkboardCard(params: { } const engine = params.engine; const mode = params.mode ?? "autonomous"; + const model = engineModel(engine); state.error = null; if (mode === "autonomous" && isScheduledForLater(params.card)) { state.error = "Scheduled cards cannot start before their scheduled time."; @@ -265,7 +275,7 @@ export async function startWorkboardCard(params: { sessionKey: buildCardTaskSessionKey(card), ...(card.agentId ? { agentId: card.agentId } : {}), label: buildCardSessionLabel(card), - ...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}), + ...(model ? { model } : {}), message: buildCardPrompt(card), deliver: false, bootstrapContextMode: "lightweight", @@ -274,7 +284,7 @@ export async function startWorkboardCard(params: { : await requestSessionCreate(params.client, { ...(card.agentId ? { agentId: card.agentId } : {}), label: buildCardSessionLabel(card), - ...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}), + ...(model ? { model } : {}), }); const sessionKey = isRecord(created) && typeof created.sessionKey === "string" && created.sessionKey.trim() diff --git a/ui/src/lib/workboard/index.test.ts b/ui/src/lib/workboard/index.test.ts index 901e5bfacfa..5afadfb884d 100644 --- a/ui/src/lib/workboard/index.test.ts +++ b/ui/src/lib/workboard/index.test.ts @@ -25,6 +25,7 @@ import { type WorkboardCard, type WorkboardTaskSummary, } from "./index.ts"; +import { normalizeExecution, normalizeMetadata } from "./metadata-normalization.ts"; function createClient( responses: Record | ((method: string, params: unknown) => unknown), @@ -87,6 +88,39 @@ describe("workboard controller", () => { vi.useRealTimers(); }); + it("normalizes open execution engines and preserves unknown runtime metadata", () => { + expect( + normalizeExecution({ + id: "exec-claude", + engine: "claude-cli", + mode: "autonomous", + status: "running", + model: "anthropic/claude-sonnet-4-6", + startedAt: 1, + updatedAt: 2, + }), + ).toMatchObject({ + engine: "claude-cli", + model: "anthropic/claude-sonnet-4-6", + }); + + const unresolved = normalizeExecution({ + id: "exec-unresolved", + mode: "autonomous", + status: "running", + startedAt: 1, + updatedAt: 2, + }); + expect(unresolved).toBeDefined(); + expect(unresolved).not.toHaveProperty("engine"); + expect(unresolved).not.toHaveProperty("model"); + expect( + normalizeMetadata({ + attempts: [{ id: "attempt-1", engine: "claude-cli", startedAt: 1 }], + })?.attempts?.[0]?.engine, + ).toBe("claude-cli"); + }); + describe("runtime ownership", () => { it("keeps state pristine when lifecycle teardown happens before first access", () => { const host = {}; @@ -4069,10 +4103,19 @@ describe("workboard controller", () => { updatedAt: 10, }, }; - const client = createClient({ - agent: { sessionKey: sampleTaskSessionKey, runId: "run-1" }, - "tasks.list": { tasks: [sampleTask] }, - "workboard.cards.update": { card: running }, + let updateCalls = 0; + const client = createClient((method) => { + if (method === "workboard.cards.update") { + updateCalls += 1; + return { card: updateCalls === 1 ? { ...sampleCard, status: "running" } : running }; + } + if (method === "agent") { + return { sessionKey: sampleTaskSessionKey, runId: "run-1" }; + } + if (method === "tasks.list") { + return { tasks: [sampleTask] }; + } + return {}; }); await startWorkboardCard({ @@ -4107,6 +4150,7 @@ describe("workboard controller", () => { patch: expect.objectContaining({ status: "running", execution: expect.objectContaining({ + id: "card-1:agent-session", engine: "codex", mode: "autonomous", model: "openai/gpt-5.6-sol", diff --git a/ui/src/lib/workboard/metadata-normalization.ts b/ui/src/lib/workboard/metadata-normalization.ts index 17a7018be9e..deb10b0d001 100644 --- a/ui/src/lib/workboard/metadata-normalization.ts +++ b/ui/src/lib/workboard/metadata-normalization.ts @@ -8,7 +8,6 @@ import { WORKBOARD_DIAGNOSTIC_KINDS, WORKBOARD_DIAGNOSTIC_SEVERITIES, WORKBOARD_EVENT_KINDS, - WORKBOARD_EXECUTION_ENGINES, WORKBOARD_EXECUTION_MODES, WORKBOARD_EXECUTION_STATUSES, WORKBOARD_LINK_TYPES, @@ -28,7 +27,6 @@ import { type WorkboardEvent, type WorkboardEventKind, type WorkboardExecution, - type WorkboardExecutionEngine, type WorkboardExecutionMode, type WorkboardExecutionStatus, type WorkboardLink, @@ -50,9 +48,7 @@ export function normalizeExecution(value: unknown): WorkboardExecution | undefin return undefined; } const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : ""; - const engine = WORKBOARD_EXECUTION_ENGINES.includes(value.engine as WorkboardExecutionEngine) - ? (value.engine as WorkboardExecutionEngine) - : null; + const engine = typeof value.engine === "string" ? value.engine.trim() : ""; const mode = WORKBOARD_EXECUTION_MODES.includes(value.mode as WorkboardExecutionMode) ? (value.mode as WorkboardExecutionMode) : null; @@ -62,18 +58,18 @@ export function normalizeExecution(value: unknown): WorkboardExecution | undefin const model = typeof value.model === "string" && value.model.trim() ? value.model.trim() : ""; const startedAt = typeof value.startedAt === "number" ? value.startedAt : 0; const updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : startedAt; - if (!id || !engine || !mode || !model || !startedAt) { + if (!id || !mode || !startedAt) { return undefined; } return { id, kind: "agent-session", - engine, mode, status, - model, startedAt, updatedAt, + ...(engine ? { engine } : {}), + ...(model ? { model } : {}), ...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}), ...(typeof value.runId === "string" ? { runId: value.runId } : {}), }; @@ -142,15 +138,14 @@ export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined const status = WORKBOARD_ATTEMPT_STATUSES.includes(entry.status as WorkboardAttemptStatus) ? (entry.status as WorkboardAttemptStatus) : "running"; + const engine = typeof entry.engine === "string" ? entry.engine.trim() : ""; return [ { id: entry.id, status, startedAt: entry.startedAt, ...(typeof entry.endedAt === "number" ? { endedAt: entry.endedAt } : {}), - ...(WORKBOARD_EXECUTION_ENGINES.includes(entry.engine as WorkboardExecutionEngine) - ? { engine: entry.engine as WorkboardExecutionEngine } - : {}), + ...(engine ? { engine } : {}), ...(WORKBOARD_EXECUTION_MODES.includes(entry.mode as WorkboardExecutionMode) ? { mode: entry.mode as WorkboardExecutionMode } : {}), diff --git a/ui/src/pages/workboard/view.ts b/ui/src/pages/workboard/view.ts index 63df03b04a0..64fe99021c8 100644 --- a/ui/src/pages/workboard/view.ts +++ b/ui/src/pages/workboard/view.ts @@ -1299,7 +1299,9 @@ function renderLifecycle(
${taskStatus ?? - (stale || !execution ? formatted.label : `${execution.engine} ${execution.mode}`)} + (stale || !execution + ? formatted.label + : `${execution.engine ? `${execution.engine} ` : ""}${execution.mode}`)} ${task && taskIsAuthoritative