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
This commit is contained in:
Peter Steinberger
2026-07-16 04:10:27 -07:00
committed by GitHub
parent f538addc66
commit d5eebf02e2
16 changed files with 333 additions and 73 deletions
@@ -24,6 +24,61 @@ function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T>
}
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({
+9 -7
View File
@@ -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<PluginRuntime["subagent"], "run">;
export type WorkboardWorktreeRuntime = PluginRuntime["worktrees"];
@@ -94,16 +93,20 @@ function buildExecution(params: {
card: WorkboardCard;
sessionKey: string;
runId: string;
model: string;
runtime: Awaited<ReturnType<WorkboardSubagentRuntime["run"]>>["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 } : {}),
+4 -2
View File
@@ -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") }
: {}),
@@ -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 }),
+20 -28
View File
@@ -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<string, unknown>;
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;
}
+59
View File
@@ -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<T = PersistedWorkboardCard>(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());
+4 -3
View File
@@ -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;
@@ -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({
@@ -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);
+30
View File
@@ -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);
+16 -2
View File
@@ -348,6 +348,19 @@ export async function dispatchTrustedPluginGatewayMethod<T>(
const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000;
function normalizeSubagentRunRuntime(
value: unknown,
): Awaited<ReturnType<PluginRuntime["subagent"]["run"]>>["runtime"] {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
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 }>(
+5
View File
@@ -35,6 +35,11 @@ type PluginManagedWorktree = {
type SubagentRunResult = {
runId: string;
runtime?: {
harness: string;
provider: string;
model: string;
};
};
type SubagentWaitParams = {
+14 -4
View File
@@ -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()
+48 -4
View File
@@ -25,6 +25,7 @@ import {
type WorkboardCard,
type WorkboardTaskSummary,
} from "./index.ts";
import { normalizeExecution, normalizeMetadata } from "./metadata-normalization.ts";
function createClient(
responses: Record<string, unknown> | ((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",
+6 -11
View File
@@ -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 }
: {}),
+3 -1
View File
@@ -1299,7 +1299,9 @@ function renderLifecycle(
<div class="workboard-card__lifecycle">
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${taskStatus ??
(stale || !execution ? formatted.label : `${execution.engine} ${execution.mode}`)}
(stale || !execution
? formatted.label
: `${execution.engine ? `${execution.engine} ` : ""}${execution.mode}`)}
</span>
<span class="workboard-card__lifecycle-detail">
${task && taskIsAuthoritative