mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -35,6 +35,11 @@ type PluginManagedWorktree = {
|
||||
|
||||
type SubagentRunResult = {
|
||||
runId: string;
|
||||
runtime?: {
|
||||
harness: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SubagentWaitParams = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user