Files
openclaw/extensions/workboard/src/dispatcher.test.ts
Peter Steinberger ffdd0641c8 fix(workboard): retry managed worktree cleanup after hook failures (#126162)
* fix(workboard): retry managed worktree cleanup

* fix(workboard): keep workspace mutation type local
2026-08-18 22:05:16 -07:00

1078 lines
35 KiB
TypeScript

// Workboard tests cover dispatcher plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
return {
async register(key, value) {
entries.set(key, value);
},
async lookup(key) {
return entries.get(key);
},
async delete(key) {
return entries.delete(key);
},
async entries() {
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
},
};
}
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, and persists them", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Isolated worker",
status: "ready",
workspace: { kind: "worktree", path: "/repo", branch: "main" },
workspaceAccess: { unrestricted: true },
});
const run = vi.fn().mockResolvedValue({ runId: "run-worktree" });
const worktrees = {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({
id: "managed-id",
path: "/state/worktrees/fingerprint/wb-card",
branch: `openclaw/wb-${card.id}`,
}),
release: vi.fn(),
removeIfLossless: vi.fn().mockResolvedValue(true),
};
await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees,
options: {
now: 10,
maxStarts: 1,
materializeWorktree: true,
},
});
expect(worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: "/repo",
baseRef: "main",
ownerKind: "workboard",
ownerId: card.id,
}),
);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/state/worktrees/fingerprint/wb-card" }),
);
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: {
automation: {
workspace: {
kind: "worktree",
path: "/state/worktrees/fingerprint/wb-card",
branch: `openclaw/wb-${card.id}`,
sourcePath: "/repo",
sourceBranch: "main",
},
},
},
});
expect(worktrees.removeIfLossless).not.toHaveBeenCalled();
});
it("requires explicit reauthorization for legacy cards under full-host dispatch", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Legacy worker",
status: "ready",
workspace: { kind: "worktree", path: "/repo" },
});
const run = vi.fn();
const create = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue("/repo"),
create,
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: { maxStarts: 1, materializeWorktree: true },
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error:
"card workspace authority is unknown; re-save its workspace with current permissions before dispatch.",
}),
]);
expect(run).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect((await store.get(card.id))?.metadata?.automation?.workspaceAccess).toBeUndefined();
});
it("adopts current authority for a legacy card without a host workspace path", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Legacy scratch worker", status: "ready" });
const run = vi.fn().mockResolvedValue({ runId: "run-legacy-scratch" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { maxStarts: 1 },
});
expect(result.startFailures).toEqual([]);
expect(run).toHaveBeenCalledOnce();
expect((await store.get(card.id))?.metadata?.automation?.workspaceAccess).toEqual({
unrestricted: true,
});
});
it("does not claim a card whose workspace authority changed after preflight", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Racing authority update",
status: "ready",
workspace: { kind: "dir", path: "/workspace" },
workspaceAccess: { unrestricted: true },
});
const originalClaim = store.claim.bind(store);
vi.spyOn(store, "claim").mockImplementationOnce(async (id, input, options) => {
await store.update(id, {
workspaceAccess: {
unrestricted: false,
roots: ["/workspace"],
writable: true,
},
});
return await originalClaim(id, input, options);
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { maxStarts: 1, workspaceAccess: { unrestricted: true } },
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "card workspace authority changed before claim.",
}),
]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
});
it("rejects worktree sources outside the dispatcher's workspace boundary", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Protected checkout",
status: "ready",
workspace: { kind: "worktree", path: "/repo" },
});
const worktrees = {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
};
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run: vi.fn() },
worktrees,
options: {
maxStarts: 1,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace path is outside the caller's allowed workspaces.",
}),
]);
expect(worktrees.create).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
expect((await store.get(card.id))?.metadata?.automation?.workspaceAccess).toBeUndefined();
});
it("leaves inaccessible directory workspaces ready and unclaimed", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Protected directory",
status: "ready",
workspace: { kind: "dir", path: "/outside" },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: {
maxStarts: 1,
materializeWorktree: false,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace path is outside the caller's allowed workspaces.",
}),
]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { workspace: { kind: "dir", path: "/outside" } } },
});
});
it("does not launch a mutable nested directory for a workspace-bound caller", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Mutable nested directory",
status: "ready",
workspace: { kind: "dir", path: "/workspace/repo" },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: {
maxStarts: 1,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace path must equal one of the caller's allowed workspace roots.",
}),
]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
});
it("does not let an implicit target agent workspace widen caller access", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Other agent scratch",
status: "ready",
agentId: "other",
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: {
maxStarts: 1,
resolveAgentWorkspace: (agentId) =>
agentId === "other" ? "/workspace-other" : "/workspace",
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace path must equal one of the caller's allowed workspace roots.",
}),
]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
});
it("pins an allowed implicit worker to the caller's workspace root", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Workspace scratch",
status: "ready",
agentId: "main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-scratch" });
const worktrees = {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
};
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees,
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
}),
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
});
expect(result.started).toHaveLength(1);
expect(run).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/workspace" }));
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: {
automation: {
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
},
});
});
it("rejects a restricted card when the target agent is not sandboxed", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Restricted worker",
status: "ready",
agentId: "broad",
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: false,
workspaceAccess: { unrestricted: true },
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "target agent is not sandboxed for this restricted Workboard card.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("rejects a restricted card when the target workspace is read-only", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Read-only worker",
status: "ready",
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: false },
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "target agent does not have writable workspace-only access.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("keeps read-only card authority after a later full-host dispatch", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Persisted read-only worker",
status: "ready",
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: false },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { maxStarts: 1, workspaceAccess: { unrestricted: true } },
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: expect.stringContaining("manual movement is allowed"),
}),
]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
});
it("rejects a target sandbox root broader than the card authority", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Broader target worker",
status: "ready",
workspace: { kind: "dir", path: "/workspace/project" },
workspaceAccess: {
unrestricted: false,
roots: ["/workspace/project"],
writable: true,
},
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace path must equal one of the caller's allowed workspace roots.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("rejects a restricted card when the target sandbox has an escape path", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Escaping worker",
status: "ready",
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
confinementError: "target sandbox routes shell execution outside the sandbox.",
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "target sandbox routes shell execution outside the sandbox.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("rejects a restricted workspace nested inside a broader Git checkout", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Nested checkout worker",
status: "ready",
workspace: { kind: "dir", path: "/repo/workspace" },
workspaceAccess: { unrestricted: false, roots: ["/repo/workspace"], writable: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue("/repo"),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/repo/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: {
unrestricted: false,
roots: ["/repo/workspace"],
writable: true,
},
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "workspace root is nested inside a broader Git checkout.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("keeps a card's persisted workspace ceiling during a later admin dispatch", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Persisted restricted worker",
status: "ready",
workspace: { kind: "worktree", path: "/workspace" },
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
});
const create = vi.fn();
const run = vi.fn().mockResolvedValue({ runId: "run-persisted" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue("/workspace"),
hasSelfContainedCheckoutMetadata: vi.fn().mockResolvedValue(true),
create,
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
materializeWorktree: true,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
}),
workspaceAccess: { unrestricted: true },
},
});
expect(result.started).toHaveLength(1);
expect(create).not.toHaveBeenCalled();
expect(run).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/workspace" }));
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: {
automation: {
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
},
},
});
});
it("runs an authorized worktree request directly in a workspace-bound caller's root", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Workspace-bound worker",
status: "ready",
workspace: { kind: "worktree", path: "/repo" },
});
const run = vi.fn().mockResolvedValue({ runId: "run-workspace" });
const worktrees = {
resolveCheckoutRoot: vi.fn().mockResolvedValue("/repo"),
hasSelfContainedCheckoutMetadata: vi.fn().mockResolvedValue(true),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
};
await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees,
options: {
maxStarts: 1,
materializeWorktree: true,
resolveAgentWorkspace: () => "/repo",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/repo"], writable: true },
}),
workspaceAccess: { unrestricted: false, roots: ["/repo"], writable: true },
},
});
expect(run).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repo" }));
expect(worktrees.create).not.toHaveBeenCalled();
await expect(store.get(card.id)).resolves.toMatchObject({
metadata: { automation: { workspace: { kind: "dir", path: "/repo" } } },
});
});
it("rejects linked-worktree metadata outside a restricted workspace mount", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Linked worktree worker",
status: "ready",
workspace: { kind: "dir", path: "/workspace" },
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue("/workspace"),
hasSelfContainedCheckoutMetadata: vi.fn().mockResolvedValue(false),
create: vi.fn(),
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: {
maxStarts: 1,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
}),
},
});
expect(result.startFailures).toEqual([
expect.objectContaining({
cardId: card.id,
error: "restricted workspace Git metadata must be contained inside its root.",
}),
]);
expect(run).not.toHaveBeenCalled();
});
it("does not reuse a generated branch as an omitted source base", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Branchless retry",
status: "ready",
workspace: {
kind: "worktree",
path: "/state/worktrees/fingerprint/wb-card",
branch: "openclaw/wb-card",
sourcePath: "/repo",
},
workspaceAccess: { unrestricted: true },
});
const create = vi.fn().mockResolvedValue({
id: "managed-id",
path: "/state/worktrees/fingerprint/wb-card",
branch: "openclaw/wb-card",
});
await dispatchAndStartWorkboardCards({
store,
subagent: { run: vi.fn().mockResolvedValue({ runId: "run-retry" }) },
worktrees: {
resolveCheckoutRoot: vi.fn().mockResolvedValue(undefined),
create,
release: vi.fn(),
removeIfLossless: vi.fn(),
},
options: { maxStarts: 1, materializeWorktree: true },
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ repoRoot: "/repo", ownerId: card.id }),
);
expect(create.mock.calls[0]?.[0]).not.toHaveProperty("baseRef");
});
it("claims ready cards and starts bounded subagent worker runs", async () => {
const store = new WorkboardStore(createMemoryStore());
const first = await store.create({
title: "First worker",
status: "ready",
priority: "urgent",
agentId: "codex-main",
workspaceAccess: { unrestricted: true },
});
const second = await store.create({
title: "Second worker",
status: "ready",
priority: "normal",
agentId: "codex-main",
workspaceAccess: { unrestricted: true },
});
const otherAgent = await store.create({
title: "Other worker",
status: "ready",
priority: "high",
agentId: "codex-side",
workspaceAccess: { unrestricted: true },
});
const run = vi
.fn()
.mockResolvedValueOnce({ runId: "run-first" })
.mockResolvedValueOnce({ runId: "run-other" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started.map((entry) => entry.cardId).toSorted()).toEqual(
[first.id, otherAgent.id].toSorted(),
);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
lane: `workboard:default:${first.id}`,
deliver: false,
});
expect(run.mock.calls[0]?.[0]?.message).toContain("Claim token:");
expect(run.mock.calls[0]?.[0]?.message).toContain("workboard_complete with the card id");
expect(run.mock.calls[0]?.[0]?.message).toContain("returned proofId");
expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token");
await expect(store.get(first.id)).resolves.toMatchObject({
status: "running",
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
runId: "run-first",
execution: { status: "running", runId: "run-first" },
metadata: {
claim: { ownerId: "codex-main" },
workerLogs: [expect.objectContaining({ message: expect.stringContaining("run-first") })],
},
});
expect(run.mock.calls[0]?.[0]?.toolsAlsoAllow).toEqual([
"workboard_heartbeat",
"workboard_complete",
"workboard_block",
]);
await expect(store.get(second.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { dispatchCount: 1 } },
});
});
it("shares one worker slot across cards dispatched with the same explicit owner", async () => {
const store = new WorkboardStore(createMemoryStore());
const first = await store.create({
title: "First shared worker",
status: "ready",
priority: "urgent",
agentId: "alpha",
workspaceAccess: { unrestricted: true },
});
const second = await store.create({
title: "Second shared worker",
status: "ready",
agentId: "beta",
workspaceAccess: { unrestricted: true },
});
const run = vi.fn().mockResolvedValue({ runId: "run-shared" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3, ownerId: " shared-worker " },
});
expect(result.started).toEqual([
expect.objectContaining({ cardId: first.id, runId: "run-shared" }),
]);
expect(run).toHaveBeenCalledOnce();
await expect(store.get(first.id)).resolves.toMatchObject({
status: "running",
metadata: { claim: { ownerId: "shared-worker" } },
});
await expect(store.get(second.id)).resolves.toMatchObject({ status: "ready" });
});
it("counts the active claim owner when checking worker capacity", async () => {
const store = new WorkboardStore(createMemoryStore());
const running = await store.create({
title: "Already claimed worker",
status: "running",
agentId: "alpha",
workspaceAccess: { unrestricted: true },
});
await store.claim(running.id, { ownerId: "shared-worker", token: "shared-token" });
const ready = await store.create({
title: "Waiting for the shared owner",
status: "ready",
agentId: "beta",
workspaceAccess: { unrestricted: true },
});
const run = vi.fn();
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3, ownerId: "shared-worker" },
});
expect(result.started).toEqual([]);
expect(run).not.toHaveBeenCalled();
await expect(store.get(ready.id)).resolves.toMatchObject({ status: "ready" });
});
it.each(["worker", "other-worker"])(
"starts recoverable work after a higher-priority worker fails (next owner: %s)",
async (nextOwner) => {
const store = new WorkboardStore(createMemoryStore());
const failed = await store.create({
title: "Unavailable urgent worker",
status: "ready",
priority: "urgent",
agentId: "worker",
workspaceAccess: { unrestricted: true },
});
const recovered = await store.create({
title: "Recoverable queued worker",
status: "ready",
agentId: nextOwner,
workspaceAccess: { unrestricted: true },
});
const run = vi
.fn()
.mockRejectedValueOnce(new Error("model unavailable"))
.mockResolvedValueOnce({ runId: "run-recovered" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 1 },
});
expect(run).toHaveBeenCalledTimes(2);
expect(result.started).toEqual([
expect.objectContaining({ cardId: recovered.id, runId: "run-recovered" }),
]);
expect(result.startFailures).toEqual([
expect.objectContaining({ cardId: failed.id, error: "model unavailable" }),
]);
await expect(store.get(failed.id)).resolves.toMatchObject({ status: "blocked" });
await expect(store.get(recovered.id)).resolves.toMatchObject({ status: "running" });
},
);
it("does not let review cards consume an agent running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
await store.create({
title: "Waiting for operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
const ready = await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
workspaceAccess: { unrestricted: true },
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([
expect.objectContaining({
cardId: ready.id,
runId: "run-next",
}),
]);
expect(run).toHaveBeenCalledOnce();
});
it("starts workers only for the selected board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({
title: "Ops worker",
status: "ready",
priority: "urgent",
boardId: "ops",
workspaceAccess: { unrestricted: true },
});
const product = await store.create({
title: "Product worker",
status: "ready",
priority: "urgent",
boardId: "product",
workspaceAccess: { unrestricted: true },
});
const run = vi.fn().mockResolvedValue({ runId: "run-ops" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3, boardId: "ops" },
});
expect(result.started).toEqual([expect.objectContaining({ cardId: ops.id })]);
expect(run).toHaveBeenCalledOnce();
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `subagent:workboard-ops-${ops.id}`,
lane: `workboard:ops:${ops.id}`,
});
await expect(store.get(product.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { boardId: "product" } },
});
});
it("keeps claimed review cards in the owner running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({
title: "Claimed operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
await store.claim(review.id, { ownerId: "codex-main", token: "review-token" });
await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([]);
expect(run).not.toHaveBeenCalled();
});
it("blocks a card when worker start fails after claim", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Fail worker",
status: "ready",
workspaceAccess: { unrestricted: true },
});
const run = vi.fn().mockRejectedValue(new Error("model unavailable"));
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 1 },
});
expect(result.started).toEqual([]);
expect(result.startFailures).toEqual([
expect.objectContaining({ cardId: card.id, error: "model unavailable" }),
]);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: `subagent:workboard-default-${card.id}`,
}),
);
await expect(store.get(card.id)).resolves.toMatchObject({
status: "blocked",
metadata: {
comments: [
expect.objectContaining({
body: expect.stringContaining("Dispatcher could not start worker"),
}),
],
},
});
expect((await store.get(card.id))?.metadata?.claim).toBeUndefined();
});
});