From ed46e62bcc5db111ce2c0d541aeaa25eb7bdbe19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 10:31:56 +0100 Subject: [PATCH] feat(workboard): add worker dispatch CLI * feat(workboard): add worker dispatch CLI * fix(workboard): avoid new unsafe assertions * fix(workboard): keep remote dispatch failures remote --- docs/cli/index.md | 10 +- docs/cli/workboard.md | 221 ++++++++++++++++++ docs/docs.json | 8 +- docs/plugins/workboard.md | 106 ++++++++- extensions/workboard/index.ts | 17 ++ extensions/workboard/openclaw.plugin.json | 10 +- extensions/workboard/src/card-lookup.ts | 24 ++ extensions/workboard/src/cli.test.ts | 154 +++++++++++++ extensions/workboard/src/cli.ts | 235 +++++++++++++++++++ extensions/workboard/src/command.test.ts | 114 ++++++++++ extensions/workboard/src/command.ts | 161 +++++++++++++ extensions/workboard/src/dispatcher.test.ts | 110 +++++++++ extensions/workboard/src/dispatcher.ts | 240 ++++++++++++++++++++ extensions/workboard/src/gateway.ts | 6 +- 14 files changed, 1404 insertions(+), 12 deletions(-) create mode 100644 docs/cli/workboard.md create mode 100644 extensions/workboard/src/card-lookup.ts create mode 100644 extensions/workboard/src/cli.test.ts create mode 100644 extensions/workboard/src/cli.ts create mode 100644 extensions/workboard/src/command.test.ts create mode 100644 extensions/workboard/src/command.ts create mode 100644 extensions/workboard/src/dispatcher.test.ts create mode 100644 extensions/workboard/src/dispatcher.ts diff --git a/docs/cli/index.md b/docs/cli/index.md index 8b37b1a3af5..3572806a87e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -35,7 +35,7 @@ Use the setup commands by intent: | Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) | | Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) | | Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) | -| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) (if installed) | +| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) · [`workboard`](/cli/workboard) (if installed) | ## Global flags @@ -124,6 +124,11 @@ openclaw [--dev] [--profile ] disable doctor marketplace list + workboard + list + create + show + dispatch memory status index @@ -368,7 +373,8 @@ openclaw [--dev] [--profile ] terminal (alias: tui --local) ``` -Plugins can add additional top-level commands (for example `openclaw voicecall`). +Plugins can add additional top-level commands, such as +[`openclaw workboard`](/cli/workboard) or `openclaw voicecall`. diff --git a/docs/cli/workboard.md b/docs/cli/workboard.md new file mode 100644 index 00000000000..6d87cc931a6 --- /dev/null +++ b/docs/cli/workboard.md @@ -0,0 +1,221 @@ +--- +summary: "CLI reference for `openclaw workboard` cards, dispatch, and worker runs" +read_when: + - You want to inspect or create Workboard cards from the terminal + - You want to dispatch Workboard worker runs from the CLI + - You are debugging Workboard CLI or slash command behavior +title: "Workboard CLI" +--- + +`openclaw workboard` is the terminal surface for the bundled +[Workboard plugin](/plugins/workboard). It lets an operator list cards, create a +card, inspect one card, and ask the running Gateway to dispatch ready work into +subagent worker runs. + +Enable the plugin before using the command: + +```bash +openclaw plugins enable workboard +openclaw gateway restart +``` + +## Usage + +```bash +openclaw workboard list [--board ] [--status ] [--json] +openclaw workboard create [--notes ] [--status ] [--priority ] [--agent ] [--board ] [--labels ] [--json] +openclaw workboard show [--json] +openclaw workboard dispatch [--url ] [--token ] [--timeout ] [--json] +``` + +The command reads and writes the same plugin-owned SQLite database used by the +dashboard and Workboard agent tools. Card ids can be passed by full id or by an +unambiguous prefix when a command accepts a card id. + +## `list` + +```bash +openclaw workboard list +openclaw workboard list --board default --status ready +openclaw workboard list --json +``` + +Text output is compact: + +```text +7f4a2c10 ready high default agent-a Fix stale worker heartbeat +``` + +Columns are id prefix, status, priority, board id, optional agent id, and title. + +Flags: + +| Flag | Purpose | +| ------------------- | ---------------------------------------- | +| `--board ` | Limit results to one board namespace | +| `--status ` | Limit results to one Workboard status | +| `--json` | Print the full card list as machine JSON | + +## `create` + +```bash +openclaw workboard create "Fix stale worker heartbeat" --priority high --labels bug,workboard +openclaw workboard create "Write Workboard docs" --status ready --agent docs-agent --board docs --notes "Cover CLI, slash command, dispatch, and SQLite state." +``` + +Flags: + +| Flag | Purpose | +| ----------------------- | --------------------------------------- | +| `--notes ` | Initial card notes | +| `--status ` | Initial status, default `todo` | +| `--priority ` | Priority, default `normal` | +| `--agent ` | Assign the card to an agent or owner id | +| `--board ` | Store the card on a board namespace | +| `--labels ` | Comma-separated labels | +| `--json` | Print the created card as machine JSON | + +`create` writes directly to Workboard SQLite state. The card is immediately +visible in the Control UI Workboard tab and to Workboard tools. + +## `show` + +```bash +openclaw workboard show 7f4a2c10 +openclaw workboard show 7f4a2c10 --json +``` + +Text output prints the compact card line and notes. JSON output returns the full +card record, including execution metadata, attempts, comments, links, proof, +artifacts, worker logs, protocol state, diagnostics, and automation metadata. + +## `dispatch` + +```bash +openclaw workboard dispatch +openclaw workboard dispatch --json +openclaw workboard dispatch --url http://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN" +``` + +`dispatch` first calls the running Gateway RPC method +`workboard.cards.dispatch`. That path uses the same subagent runtime as the +dashboard dispatch action, so ready cards can become real worker sessions. + +The dispatch loop: + +1. Promotes dependency-ready children to `ready`. +2. Blocks expired claims or timed-out worker runs. +3. Records dispatch metadata on ready cards. +4. Selects a small batch of unclaimed ready cards. +5. Claims each selected card for the dispatcher or assigned agent. +6. Starts a subagent worker run with bounded card context and the card claim + token. +7. Stores the worker run id, session key, execution status, and worker log on + the card. + +Selection is intentionally conservative. One dispatch starts at most three +workers by default, skips archived or already-claimed cards, and starts only one +card per owner or agent in a single pass. Cards already owned by active running +or review work are left for a later dispatch. + +If worker start fails after a card is claimed, Workboard blocks that card, +clears the claim, and records the failure in card execution and worker-log +metadata. This keeps failed starts visible instead of silently returning the +card to the queue. + +If no explicit Gateway target is provided and the local Gateway is unavailable +or does not expose the Workboard dispatch method yet, the CLI falls back to +data-only dispatch against local Workboard state. Data-only dispatch can still +promote dependencies, clean stale claims, and block timed-out runs, but it does +not start workers. Auth, permission, validation failures, and failures for an +explicit `--url` or `--token` target are reported directly. + +Text output reports worker starts: + +```text +dispatch complete: started=2 failures=0 +``` + +Fallback output is explicit: + +```text +gateway unavailable; data dispatch only: promoted=1 blocked=0 +``` + +JSON output includes the dispatch result. Gateway-backed dispatch can include +`started` and `startFailures`; data-only fallback includes +`gatewayUnavailable: true`. Claim tokens are redacted from card JSON output. + +## Slash Command Parity + +Command-capable channels can use the matching slash command: + +```text +/workboard list +/workboard show 7f4a2c10 +/workboard create Fix stale worker heartbeat +/workboard dispatch +``` + +Slash command dispatch also uses the Gateway subagent runtime, so it follows the +same claim, worker-start, and failure behavior as the dashboard and CLI Gateway +path. + +`/workboard list` and `/workboard show` are read commands for authorized command +senders. `/workboard create` and `/workboard dispatch` mutate board state and +require owner status on chat surfaces or a Gateway client with `operator.write` +or `operator.admin`. + +## Permissions + +The CLI dispatch path calls Gateway RPC with `operator.read` and +`operator.write` scopes. A read-only Gateway token can inspect Workboard data +through read methods, but it cannot create cards or dispatch workers. + +Local `list`, `create`, and `show` commands operate on the local OpenClaw state +directory used by the current profile. Use `--dev` or `--profile ` on the +top-level `openclaw` command when you need a different state root. + +## Troubleshooting + +### No Cards Appear + +Confirm the plugin is enabled for the same profile and state root: + +```bash +openclaw plugins inspect workboard --runtime --json +``` + +If the dashboard shows cards but the CLI does not, check that both commands use +the same `--dev` or `--profile` setting. + +### Dispatch Says Data-Only + +Start or restart the Gateway: + +```bash +openclaw gateway restart +openclaw gateway status --deep +``` + +Then retry `openclaw workboard dispatch`. Data-only fallback is useful for local +state cleanup, but worker runs need a live Gateway. + +### Dispatch Starts Nothing + +Check for at least one `ready` card without an active claim: + +```bash +openclaw workboard list --status ready +``` + +Cards can also be skipped when the same owner already has running or review +work. Move completed work to `done`, release stale claims through the Workboard +tools, or run dispatch again after the active worker finishes. + +## Related + +- [Workboard plugin](/plugins/workboard) +- [CLI reference](/cli) +- [Slash commands](/tools/slash-commands) +- [Control UI](/web/control-ui) diff --git a/docs/docs.json b/docs/docs.json index 55e480fc54f..38eb567df92 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1701,7 +1701,13 @@ }, { "group": "Plugins and skills", - "pages": ["cli/plugins", "cli/path", "cli/policy", "cli/skills"] + "pages": [ + "cli/plugins", + "cli/path", + "cli/policy", + "cli/skills", + "cli/workboard" + ] }, { "group": "Interfaces", diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index 52281edcb00..38388f416a2 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -160,17 +160,92 @@ blocked cards that need attention, repeated failures, done cards without proof, and running cards that only have a loose session link. Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating -system processes; normal OpenClaw sessions still own execution. A dispatch nudge -promotes dependency-ready cards, records dispatch metadata on ready cards, -blocks expired claims or timed-out runs, marks board-configured triage cards as -orchestration candidates, and leaves durable notification subscriptions for the -caller that delivers notifications. +system processes; normal OpenClaw subagent sessions still own execution. A +dispatch nudge promotes dependency-ready cards, records dispatch metadata on +ready cards, blocks expired claims or timed-out runs, marks board-configured +triage cards as orchestration candidates, then claims a small batch of ready +cards and starts worker runs through the Gateway subagent runtime. Workers get +bounded card context plus the claim token they need to heartbeat, complete, or +block the card through the Workboard tools. + +### Dispatch worker selection + +Each dispatch pass starts at most three workers by default. Ready cards are +ordered by priority, position, and creation time, then filtered to avoid +duplicate active ownership. A dispatch starts only one card for a given owner or +agent in the same pass, and it skips owners that already have running or review +work on the board. + +Archived cards, cards with active claims, and cards without `ready` status are +not selected for worker starts. They can still be affected by the data side of +dispatch when stale claims, dependency promotion, or timeout cleanup applies. + +### Worker prompt and lifecycle + +The worker prompt includes the card title, bounded notes and context, the +assigned board, and the Workboard worker protocol. It also includes the claim +owner and claim token so the worker can call `workboard_heartbeat`, +`workboard_complete`, or `workboard_block` without another actor taking over the +card. + +When a worker starts successfully, Workboard stores the session key, run id, +engine, mode, model label, status, and worker log on the card. The session key +is deterministic for the board and card, which makes repeated dispatches route +back to the same worker lane instead of creating unrelated sessions. + +If a worker cannot be started after a card is claimed, Workboard blocks the +card, clears the claim, records the run-start failure, and appends a worker log +line. That failure is visible in the dashboard, CLI JSON, agent tools, and card +diagnostics. + +### Dispatch entry points + +Ready-card worker starts can happen from: + +- the dashboard dispatch action +- `openclaw workboard dispatch` +- `/workboard dispatch` on a command-capable channel + +All three entry points use the Gateway subagent runtime when the Gateway is +available. The CLI has one extra operator fallback: if the Gateway is offline or +does not expose the Workboard dispatch method and no explicit `--url` or +`--token` target was provided, it runs data-only dispatch against local SQLite +state. That fallback can promote dependencies, clean stale claims, and block +timed-out runs, but it cannot start workers. Board metadata can include orchestration settings such as `autoDecompose`, `autoDecomposePerDispatch`, `defaultAssignee`, and `orchestratorProfile`. OpenClaw records the orchestration intent and exposes it in worker context; the -actual specification, decomposition, or session start still happens through the -normal Workboard tools and dashboard session flow. +actual specification and decomposition still happens through the normal +Workboard tools. + +## CLI and slash command + +The plugin registers a root CLI command: + +```bash +openclaw workboard list +openclaw workboard create "Fix stale card lifecycle" --priority high --labels bug,workboard +openclaw workboard show +openclaw workboard dispatch +``` + +`openclaw workboard dispatch` calls the running Gateway so worker starts use the +same subagent runtime as the dashboard. If the Gateway is unavailable, it falls +back to data-only dispatch so dependency promotion, stale-claim cleanup, and +timeout blocking can still run. Auth, permission, and validation failures still +surface as command errors, as do failures for explicit `--url` or `--token` +targets. + +The `/workboard` slash command supports the same compact operator path: +`/workboard list`, `/workboard show `, `/workboard create `, and +`/workboard dispatch`. List and show are read operations for authorized command +senders. Create and dispatch require owner status on chat surfaces or a Gateway +client with `operator.write` or `operator.admin`. + +See [Workboard CLI](/cli/workboard) for command flags, JSON output, Gateway +fallback behavior, unambiguous id-prefix handling, dispatch selection rules, and +troubleshooting. ## Session lifecycle sync @@ -289,9 +364,26 @@ Workboard creates links to normal dashboard sessions. Check the card's agent id and linked session, then open the Sessions or Chat view to inspect the actual run state. +### Dispatch does not start a worker + +Confirm there is at least one `ready` card without an active claim: + +```bash +openclaw workboard list --status ready +``` + +If the CLI reports data-only dispatch, start or restart the Gateway and retry. +Data-only dispatch updates local board state but cannot start subagent worker +runs. + +Cards can also be skipped when another card for the same owner or agent is +already running or waiting for review. Complete, block, or release that active +work before dispatching more work for the same owner. + ## Related - [Control UI](/web/control-ui) +- [Workboard CLI](/cli/workboard) - [Plugins](/tools/plugin) - [Manage plugins](/plugins/manage-plugins) - [Sessions](/concepts/session) diff --git a/extensions/workboard/index.ts b/extensions/workboard/index.ts index d10904931e8..74a55b767f6 100644 --- a/extensions/workboard/index.ts +++ b/extensions/workboard/index.ts @@ -1,5 +1,6 @@ import { definePluginEntry } from "./api.js"; import { registerWorkboardGatewayMethods } from "./runtime-api.js"; +import { registerWorkboardCommand } from "./src/command.js"; import { WorkboardStore } from "./src/store.js"; import { createWorkboardTools } from "./src/tools.js"; @@ -10,6 +11,22 @@ export default definePluginEntry({ register(api) { const store = WorkboardStore.openSqlite(); registerWorkboardGatewayMethods({ api, store }); + registerWorkboardCommand({ api, store }); + api.registerCli( + async ({ program }) => { + const { registerWorkboardCli } = await import("./src/cli.js"); + registerWorkboardCli({ program, store }); + }, + { + descriptors: [ + { + name: "workboard", + description: "Manage Workboard cards and worker dispatch", + hasSubcommands: true, + }, + ], + }, + ); api.registerTool((context) => createWorkboardTools({ api, context, store }), { names: [ "workboard_list", diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json index 4469190c39f..53a40e4f259 100644 --- a/extensions/workboard/openclaw.plugin.json +++ b/extensions/workboard/openclaw.plugin.json @@ -2,7 +2,8 @@ "id": "workboard", "enabledByDefault": false, "activation": { - "onStartup": true + "onStartup": true, + "onCommands": ["workboard"] }, "name": "Workboard", "description": "Dashboard workboard for agent-owned issues and sessions.", @@ -44,6 +45,13 @@ "workboard_unblock" ] }, + "commandAliases": [ + { + "name": "workboard", + "kind": "runtime-slash", + "cliCommand": "workboard" + } + ], "toolMetadata": { "workboard_list": { "optional": true diff --git a/extensions/workboard/src/card-lookup.ts b/extensions/workboard/src/card-lookup.ts new file mode 100644 index 00000000000..5f87dd77952 --- /dev/null +++ b/extensions/workboard/src/card-lookup.ts @@ -0,0 +1,24 @@ +import type { WorkboardCard } from "./types.js"; + +export type WorkboardCardLookupResult = + | { card: WorkboardCard; error?: undefined } + | { card?: undefined; error: string }; + +export function resolveWorkboardCardByIdOrPrefix( + cards: readonly WorkboardCard[], + id: string, +): WorkboardCardLookupResult { + const exact = cards.find((card) => card.id === id); + if (exact) { + return { card: exact }; + } + const matches = cards.filter((card) => card.id.startsWith(id)); + if (matches.length === 0) { + return { error: `Card not found: ${id}` }; + } + if (matches.length > 1) { + return { error: `Ambiguous card id prefix: ${id} (${matches.length} matches)` }; + } + const card = matches[0]; + return card ? { card } : { error: `Card not found: ${id}` }; +} diff --git a/extensions/workboard/src/cli.test.ts b/extensions/workboard/src/cli.test.ts new file mode 100644 index 00000000000..829c3672797 --- /dev/null +++ b/extensions/workboard/src/cli.test.ts @@ -0,0 +1,154 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerWorkboardCli } from "./cli.js"; +import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js"; + +const gatewayRuntime = vi.hoisted(() => ({ + callGatewayFromCli: vi.fn(), + getRuntimeConfig: vi.fn(() => ({})), +})); + +vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => { + const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/gateway-runtime")>( + "openclaw/plugin-sdk/gateway-runtime", + ); + return { + ...actual, + callGatewayFromCli: gatewayRuntime.callGatewayFromCli, + }; +}); + +vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({ + getRuntimeConfig: gatewayRuntime.getRuntimeConfig, +})); + +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 }] : [])); + }, + }; +} + +function createProgram(store: WorkboardStore): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }); + registerWorkboardCli({ program, store }); + return program; +} + +async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> { + const seen = new Map<string, string>(); + for (let index = 0; index < 40; index += 1) { + const card = await store.create({ title: `Card ${index}` }); + const prefix = card.id.slice(0, 1); + if (seen.has(prefix)) { + return prefix; + } + seen.set(prefix, card.id); + } + throw new Error("could not create cards with a shared prefix"); +} + +async function captureStdout(run: () => Promise<void>): Promise<string> { + const chunks: string[] = []; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => { + chunks.push(String(chunk)); + return true; + }); + try { + await run(); + return chunks.join(""); + } finally { + write.mockRestore(); + } +} + +describe("registerWorkboardCli", () => { + beforeEach(() => { + gatewayRuntime.callGatewayFromCli.mockReset(); + gatewayRuntime.getRuntimeConfig.mockReset(); + gatewayRuntime.getRuntimeConfig.mockReturnValue({}); + delete process.env.OPENCLAW_GATEWAY_URL; + }); + + it("redacts claim tokens from card JSON output", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Claimed worker", status: "running" }); + await store.claim(card.id, { ownerId: "worker", token: "secret-token" }); + const program = createProgram(store); + + const listOutput = await captureStdout(async () => { + await program.parseAsync(["workboard", "list", "--json"], { from: "user" }); + }); + const showOutput = await captureStdout(async () => { + await program.parseAsync(["workboard", "show", card.id, "--json"], { from: "user" }); + }); + + expect(listOutput).not.toContain("secret-token"); + expect(showOutput).not.toContain("secret-token"); + expect(listOutput).toContain("[redacted]"); + expect(showOutput).toContain("[redacted]"); + }); + + it("does not fall back to local dispatch for explicit gateway targets", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Remote target", status: "ready" }); + const program = createProgram(store); + gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce( + new Error("connect ECONNREFUSED 127.0.0.1:18789"), + ); + + await expect( + program.parseAsync(["workboard", "dispatch", "--url", "ws://remote"], { from: "user" }), + ).rejects.toThrow("ECONNREFUSED"); + + const after = await store.get(card.id); + expect(after?.status).toBe("ready"); + expect(after?.metadata?.automation?.dispatchCount).toBeUndefined(); + }); + + it("does not fall back to local dispatch for configured remote gateways", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Configured remote target", status: "ready" }); + const program = createProgram(store); + gatewayRuntime.getRuntimeConfig.mockReturnValue({ + gateway: { mode: "remote", remote: { url: "wss://gateway.example" } }, + }); + gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce( + new Error("connect ECONNREFUSED gateway.example:443"), + ); + + await expect(program.parseAsync(["workboard", "dispatch"], { from: "user" })).rejects.toThrow( + "ECONNREFUSED", + ); + + const after = await store.get(card.id); + expect(after?.status).toBe("ready"); + expect(after?.metadata?.automation?.dispatchCount).toBeUndefined(); + }); + + it("rejects ambiguous card id prefixes", async () => { + const store = new WorkboardStore(createMemoryStore()); + const prefix = await createAmbiguousPrefix(store); + const program = createProgram(store); + + await expect( + program.parseAsync(["workboard", "show", prefix], { from: "user" }), + ).rejects.toThrow("Ambiguous card id prefix"); + }); +}); diff --git a/extensions/workboard/src/cli.ts b/extensions/workboard/src/cli.ts new file mode 100644 index 00000000000..ffd9756dbad --- /dev/null +++ b/extensions/workboard/src/cli.ts @@ -0,0 +1,235 @@ +import type { Command } from "commander"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { addGatewayClientOptions, callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js"; +import type { WorkboardDispatchResult, WorkboardStore } from "./store.js"; +import type { WorkboardCard } from "./types.js"; + +type JsonOptions = { + json?: boolean; +}; + +type GatewayOptions = JsonOptions & { + url?: string; + token?: string; + timeout?: string; + expectFinal?: boolean; +}; + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeLine(value: string): void { + process.stdout.write(`${value}\n`); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function splitLabels(value: string | undefined): string[] | undefined { + return value + ?.split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function formatCardLine(card: WorkboardCard): string { + const boardId = card.metadata?.automation?.boardId ?? "default"; + const agent = card.agentId ? ` ${card.agentId}` : ""; + return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} ${boardId}${agent} ${card.title}`; +} + +function redactClaimToken(card: WorkboardCard): WorkboardCard { + const claim = card.metadata?.claim; + if (!claim) { + return card; + } + return { + ...card, + metadata: { + ...card.metadata, + claim: { + ...claim, + token: "[redacted]", + }, + }, + }; +} + +function redactDispatchResult(result: WorkboardDispatchResult): WorkboardDispatchResult { + return { + ...result, + promoted: result.promoted.map(redactClaimToken), + reclaimed: result.reclaimed.map(redactClaimToken), + blocked: result.blocked.map(redactClaimToken), + orchestrated: result.orchestrated.map(redactClaimToken), + }; +} + +function writeCards(cards: WorkboardCard[], options: JsonOptions): void { + if (options.json) { + writeJson({ cards: cards.map(redactClaimToken) }); + return; + } + for (const card of cards) { + writeLine(formatCardLine(card)); + } +} + +async function callWorkboardGateway( + method: string, + options: GatewayOptions, + params?: unknown, +): Promise<unknown> { + return await callGatewayFromCli(method, options, params, { + mode: "cli", + scopes: ["operator.write", "operator.read"], + }); +} + +function isGatewayUnavailableError(error: unknown): boolean { + const message = formatErrorMessage(error).toLowerCase(); + return [ + "econnrefused", + "econnreset", + "ehostunreach", + "enotfound", + "gateway not connected", + "gateway unavailable", + "unknown method: workboard.cards.dispatch", + ].some((marker) => message.includes(marker)); +} + +function hasExplicitGatewayTarget(options: GatewayOptions): boolean { + return Boolean(options.url?.trim() || options.token?.trim()); +} + +function hasConfiguredRemoteGatewayTarget(): boolean { + if (process.env.OPENCLAW_GATEWAY_URL?.trim()) { + return true; + } + try { + return getRuntimeConfig().gateway?.mode === "remote"; + } catch { + return false; + } +} + +export function registerWorkboardCli(params: { program: Command; store: WorkboardStore }): void { + const workboard = params.program + .command("workboard") + .description("Manage Workboard cards and worker dispatch"); + + workboard + .command("list") + .description("List Workboard cards") + .option("--board <id>", "Board id") + .option("--status <status>", "Filter by status") + .option("--json", "Print JSON", false) + .action(async (options: JsonOptions & { board?: string; status?: string }) => { + let cards = await params.store.list({ boardId: options.board }); + if (options.status) { + cards = cards.filter((card) => card.status === options.status); + } + writeCards(cards, options); + }); + + workboard + .command("create") + .argument("<title...>", "Card title") + .description("Create a Workboard card") + .option("--notes <text>", "Card notes") + .option("--status <status>", "Initial status", "todo") + .option("--priority <priority>", "Priority", "normal") + .option("--agent <id>", "Assigned agent id") + .option("--board <id>", "Board id") + .option("--labels <items>", "Comma-separated labels") + .option("--json", "Print JSON", false) + .action( + async ( + title: string[], + options: JsonOptions & { + notes?: string; + status?: string; + priority?: string; + agent?: string; + board?: string; + labels?: string; + }, + ) => { + const card = await params.store.create({ + title: title.join(" "), + notes: options.notes, + status: options.status, + priority: options.priority, + agentId: options.agent, + boardId: options.board, + labels: splitLabels(options.labels), + }); + if (options.json) { + writeJson({ card: redactClaimToken(card) }); + } else { + writeLine(formatCardLine(card)); + } + }, + ); + + workboard + .command("show") + .argument("<id>", "Card id or prefix") + .description("Show one Workboard card") + .option("--json", "Print JSON", false) + .action(async (id: string, options: JsonOptions) => { + const cards = await params.store.list(); + const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id); + if (!card) { + throw new Error(error); + } + if (options.json) { + writeJson({ card: redactClaimToken(card) }); + } else { + writeLine(formatCardLine(card)); + if (card.notes) { + writeLine(card.notes); + } + } + }); + + addGatewayClientOptions( + workboard + .command("dispatch") + .description("Promote ready cards and start worker runs through the Gateway") + .option("--json", "Print JSON", false), + ).action(async (options: GatewayOptions) => { + try { + const result = await callWorkboardGateway("workboard.cards.dispatch", options, {}); + if (options.json) { + writeJson(result); + } else { + const record = isRecord(result) ? result : {}; + const started = Array.isArray(record.started) ? record.started.length : 0; + const failures = Array.isArray(record.startFailures) ? record.startFailures.length : 0; + writeLine(`dispatch complete: started=${started} failures=${failures}`); + } + } catch (error) { + if ( + !isGatewayUnavailableError(error) || + hasExplicitGatewayTarget(options) || + hasConfiguredRemoteGatewayTarget() + ) { + throw error; + } + const result = redactDispatchResult(await params.store.dispatch()); + if (options.json) { + writeJson({ ...result, gatewayUnavailable: true }); + } else { + writeLine( + `gateway unavailable; data dispatch only: promoted=${result.promoted.length} blocked=${result.blocked.length}`, + ); + } + } + }); +} diff --git a/extensions/workboard/src/command.test.ts b/extensions/workboard/src/command.test.ts new file mode 100644 index 00000000000..32aa01abd97 --- /dev/null +++ b/extensions/workboard/src/command.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleWorkboardCommand } from "./command.js"; +import type { WorkboardSubagentRuntime } from "./dispatcher.js"; +import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } 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 }] : [])); + }, + }; +} + +function createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): { + runtime: { subagent: WorkboardSubagentRuntime }; +} { + return { + runtime: { + subagent: { run }, + }, + }; +} + +async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> { + const seen = new Map<string, string>(); + for (let index = 0; index < 40; index += 1) { + const card = await store.create({ title: `Card ${index}` }); + const prefix = card.id.slice(0, 1); + if (seen.has(prefix)) { + return prefix; + } + seen.set(prefix, card.id); + } + throw new Error("could not create cards with a shared prefix"); +} + +describe("handleWorkboardCommand", () => { + it("creates, lists, and dispatches workboard cards", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = createApi(); + + await expect( + handleWorkboardCommand({ + api, + store, + args: "create Ship CLI", + senderIsOwner: true, + }), + ).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("Ship CLI") })); + const card = (await store.list())[0]; + expect(card).toMatchObject({ title: "Ship CLI" }); + + await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual( + expect.objectContaining({ text: expect.stringContaining("Ship CLI") }), + ); + await store.update(card.id, { status: "ready" }); + await expect( + handleWorkboardCommand({ + api, + store, + args: "dispatch", + gatewayClientScopes: ["operator.write"], + }), + ).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("started=1") })); + expect(api.runtime.subagent.run).toHaveBeenCalledOnce(); + }); + + it("requires write access for slash mutations", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = createApi(); + const card = await store.create({ title: "Ready worker", status: "ready" }); + + await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual( + expect.objectContaining({ text: expect.stringContaining("Ready worker") }), + ); + await expect(handleWorkboardCommand({ api, store, args: "create Blocked" })).resolves.toEqual( + expect.objectContaining({ + isError: true, + text: expect.stringContaining("operator.write"), + }), + ); + await expect(handleWorkboardCommand({ api, store, args: "dispatch" })).resolves.toEqual( + expect.objectContaining({ + isError: true, + text: expect.stringContaining("operator.write"), + }), + ); + expect(api.runtime.subagent.run).not.toHaveBeenCalled(); + await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" }); + }); + + it("rejects ambiguous card id prefixes", async () => { + const store = new WorkboardStore(createMemoryStore()); + const api = createApi(); + const prefix = await createAmbiguousPrefix(store); + + await expect(handleWorkboardCommand({ api, store, args: `show ${prefix}` })).resolves.toEqual( + expect.objectContaining({ + isError: true, + text: expect.stringContaining("Ambiguous card id prefix"), + }), + ); + }); +}); diff --git a/extensions/workboard/src/command.ts b/extensions/workboard/src/command.ts new file mode 100644 index 00000000000..9ba54a92aa3 --- /dev/null +++ b/extensions/workboard/src/command.ts @@ -0,0 +1,161 @@ +import type { OpenClawPluginApi } from "../api.js"; +import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js"; +import { dispatchAndStartWorkboardCards, type WorkboardSubagentRuntime } from "./dispatcher.js"; +import type { WorkboardStore } from "./store.js"; +import type { WorkboardCard } from "./types.js"; + +const ADMIN_SCOPE = "operator.admin"; +const WRITE_SCOPE = "operator.write"; + +type WorkboardCommandApi = { + runtime: { + subagent: WorkboardSubagentRuntime; + }; +}; + +function splitArgs(input: string | undefined): string[] { + return (input ?? "").trim().split(/\s+/).filter(Boolean); +} + +function formatCardLine(card: WorkboardCard): string { + const boardId = card.metadata?.automation?.boardId ?? "default"; + const agent = card.agentId ? ` @${card.agentId}` : ""; + return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} [${boardId}]${agent} ${card.title}`; +} + +function formatCardDetails(card: WorkboardCard): string { + const lines = [ + card.title, + `id: ${card.id}`, + `status: ${card.status}`, + `priority: ${card.priority}`, + `board: ${card.metadata?.automation?.boardId ?? "default"}`, + ]; + if (card.agentId) { + lines.push(`agent: ${card.agentId}`); + } + if (card.sessionKey) { + lines.push(`session: ${card.sessionKey}`); + } + if (card.runId) { + lines.push(`run: ${card.runId}`); + } + if (card.notes) { + lines.push("", card.notes); + } + return lines.join("\n"); +} + +function normalizeTitle(tokens: string[]): string { + return tokens.join(" ").trim(); +} + +function canMutateWorkboard(params: { + senderIsOwner?: boolean; + gatewayClientScopes?: readonly string[]; +}): boolean { + const scopes = params.gatewayClientScopes; + if (scopes) { + return scopes.includes(ADMIN_SCOPE) || scopes.includes(WRITE_SCOPE); + } + return params.senderIsOwner === true; +} + +function requireWriteAccess(params: { + senderIsOwner?: boolean; + gatewayClientScopes?: readonly string[]; +}): { text: string; isError: true } | undefined { + if (canMutateWorkboard(params)) { + return undefined; + } + return { + text: `This command requires gateway scope: ${WRITE_SCOPE}.`, + isError: true, + }; +} + +export async function handleWorkboardCommand(params: { + api: WorkboardCommandApi; + store: WorkboardStore; + args?: string; + senderIsOwner?: boolean; + gatewayClientScopes?: readonly string[]; +}): Promise<{ text: string; isError?: boolean }> { + const [action = "list", ...rest] = splitArgs(params.args); + if (action === "help") { + return { + text: [ + "/workboard list", + "/workboard show <card-id>", + "/workboard create <title>", + "/workboard dispatch", + ].join("\n"), + }; + } + if (action === "list") { + const cards = (await params.store.list()).filter((card) => !card.metadata?.archivedAt); + const rows = cards.slice(0, 12).map(formatCardLine); + return { text: rows.length ? rows.join("\n") : "No Workboard cards." }; + } + if (action === "show" || action === "read") { + const id = rest[0]; + if (!id) { + return { text: "Usage: /workboard show <card-id>", isError: true }; + } + const cards = await params.store.list(); + const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id); + return card ? { text: formatCardDetails(card) } : { text: error, isError: true }; + } + if (action === "create") { + const accessError = requireWriteAccess(params); + if (accessError) { + return accessError; + } + const title = normalizeTitle(rest); + if (!title) { + return { text: "Usage: /workboard create <title>", isError: true }; + } + const card = await params.store.create({ title }); + return { text: `Created ${card.id.slice(0, 8)} ${card.title}` }; + } + if (action === "dispatch") { + const accessError = requireWriteAccess(params); + if (accessError) { + return accessError; + } + const result = await dispatchAndStartWorkboardCards({ + store: params.store, + subagent: params.api.runtime.subagent, + }); + return { + text: [ + `dispatch: started=${result.started.length} failures=${result.startFailures.length} promoted=${result.promoted.length} blocked=${result.blocked.length}`, + ...result.started.map((run) => `started ${run.cardId.slice(0, 8)} run=${run.runId}`), + ...result.startFailures.map( + (failure) => `failed ${failure.cardId.slice(0, 8)} ${failure.error}`, + ), + ].join("\n"), + }; + } + return { text: `Unknown Workboard action: ${action}`, isError: true }; +} + +export function registerWorkboardCommand(params: { + api: OpenClawPluginApi; + store: WorkboardStore; +}): void { + params.api.registerCommand({ + name: "workboard", + description: "List, create, inspect, and dispatch Workboard cards.", + acceptsArgs: true, + exposeSenderIsOwner: true, + handler: async (ctx) => + await handleWorkboardCommand({ + api: params.api, + store: params.store, + args: ctx.args, + senderIsOwner: ctx.senderIsOwner, + gatewayClientScopes: ctx.gatewayClientScopes, + }), + }); +} diff --git a/extensions/workboard/src/dispatcher.test.ts b/extensions/workboard/src/dispatcher.test.ts new file mode 100644 index 00000000000..58ad214e95a --- /dev/null +++ b/extensions/workboard/src/dispatcher.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatchAndStartWorkboardCards } from "./dispatcher.js"; +import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } 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("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", + }); + const second = await store.create({ + title: "Second worker", + status: "ready", + priority: "normal", + agentId: "codex-main", + }); + const otherAgent = await store.create({ + title: "Other worker", + status: "ready", + priority: "high", + agentId: "codex-side", + }); + 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: `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).not.toContain("ownerId and token"); + await expect(store.get(first.id)).resolves.toMatchObject({ + status: "running", + sessionKey: `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") })], + }, + }); + await expect(store.get(second.id)).resolves.toMatchObject({ + status: "ready", + metadata: { automation: { dispatchCount: 1 } }, + }); + }); + + 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" }); + 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" }), + ]); + 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(); + }); +}); diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts new file mode 100644 index 00000000000..64166f6c486 --- /dev/null +++ b/extensions/workboard/src/dispatcher.ts @@ -0,0 +1,240 @@ +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { WorkboardStore, type WorkboardDispatchResult } from "./store.js"; +import type { WorkboardCard, WorkboardExecution, WorkboardStatus } from "./types.js"; + +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 WorkboardDispatchStartOptions = { + maxStarts?: number; + model?: string; + provider?: string; + ownerId?: string; + now?: number; +}; + +export type WorkboardStartedRun = { + cardId: string; + title: string; + sessionKey: string; + runId: string; +}; + +export type WorkboardStartFailure = { + cardId: string; + title: string; + error: string; +}; + +export type WorkboardDispatchAndStartResult = WorkboardDispatchResult & { + started: WorkboardStartedRun[]; + startFailures: WorkboardStartFailure[]; +}; + +function normalizePositiveInteger(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.trunc(value)) + : fallback; +} + +function cardBoardId(card: WorkboardCard): string { + return card.metadata?.automation?.boardId ?? "default"; +} + +function cardIsArchived(card: WorkboardCard): boolean { + return Boolean(card.metadata?.archivedAt); +} + +function buildSessionKey(card: WorkboardCard): string { + return `workboard-${cardBoardId(card)}-${card.id}`.replace(/[^a-zA-Z0-9:_-]/g, "-"); +} + +function buildExecution(params: { + card: WorkboardCard; + sessionKey: string; + runId: string; + model: string; + now: number; +}): WorkboardExecution { + return { + id: params.card.execution?.id ?? `${params.card.id}:codex`, + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: params.model, + sessionKey: params.sessionKey, + runId: params.runId, + startedAt: params.now, + updatedAt: params.now, + }; +} + +function buildWorkerPrompt(params: { + card: WorkboardCard; + context: string; + ownerId: string; + token: string; +}): string { + return [ + `Work on this OpenClaw Workboard card: ${params.card.title}`, + "", + "## Worker protocol", + `Card id: ${params.card.id}`, + `Claim ownerId: ${params.ownerId}`, + `Claim token: ${params.token}`, + "", + "Heartbeat with workboard_heartbeat using the card id and token while working.", + "When done, call workboard_complete with the card id, token, summary, and proof.", + "If blocked, call workboard_block with the card id, token, and reason.", + "", + params.context, + ].join("\n"); +} + +function sortReadyCards(a: WorkboardCard, b: WorkboardCard): number { + const priorityRank: Record<WorkboardCard["priority"], number> = { + urgent: 0, + high: 1, + normal: 2, + low: 3, + }; + return ( + priorityRank[a.priority] - priorityRank[b.priority] || + a.position - b.position || + a.createdAt - b.createdAt + ); +} + +function selectStartableCards(cards: WorkboardCard[], limit: number): WorkboardCard[] { + if (limit <= 0) { + return []; + } + const activeStatuses = new Set<WorkboardStatus>(["running", "review"]); + const runningByOwner = new Map<string, number>(); + for (const card of cards) { + if (!activeStatuses.has(card.status) || cardIsArchived(card)) { + continue; + } + const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER; + runningByOwner.set(owner, (runningByOwner.get(owner) ?? 0) + 1); + } + const selected: WorkboardCard[] = []; + for (const card of cards + .filter((entry) => entry.status === "ready" && !entry.metadata?.claim && !cardIsArchived(entry)) + .toSorted(sortReadyCards)) { + const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER; + if ((runningByOwner.get(owner) ?? 0) > 0) { + continue; + } + selected.push(card); + runningByOwner.set(owner, 1); + if (selected.length >= limit) { + break; + } + } + return selected; +} + +export async function dispatchAndStartWorkboardCards(params: { + store: WorkboardStore; + subagent: WorkboardSubagentRuntime; + options?: WorkboardDispatchStartOptions; +}): Promise<WorkboardDispatchAndStartResult> { + const now = params.options?.now ?? Date.now(); + const dispatch = await params.store.dispatch(now); + const maxStarts = normalizePositiveInteger( + params.options?.maxStarts, + DEFAULT_DISPATCH_MAX_STARTS, + ); + const started: WorkboardStartedRun[] = []; + const startFailures: WorkboardStartFailure[] = []; + const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL; + const cards = await params.store.list(); + + for (const card of selectStartableCards(cards, maxStarts)) { + const ownerId = params.options?.ownerId?.trim() || card.agentId || DEFAULT_DISPATCH_OWNER; + const sessionKey = buildSessionKey(card); + let token = ""; + try { + const claimed = await params.store.claim(card.id, { + ownerId, + ttlSeconds: card.metadata?.automation?.maxRuntimeSeconds, + }); + token = claimed.token; + const context = await params.store.buildWorkerContext(card.id); + const run = await params.subagent.run({ + sessionKey, + message: buildWorkerPrompt({ + card: claimed.card, + context, + ownerId, + token, + }), + ...(params.options?.provider ? { provider: params.options.provider } : {}), + ...(params.options?.model ? { model: params.options.model } : {}), + lane: `workboard:${cardBoardId(card)}:${card.id}`, + idempotencyKey: `workboard:${card.id}:${claimed.card.updatedAt}`, + lightContext: true, + deliver: false, + }); + const updated = await params.store.update(card.id, { + sessionKey, + runId: run.runId, + execution: buildExecution({ + card: claimed.card, + sessionKey, + runId: run.runId, + model, + now, + }), + }); + await params.store.addWorkerLog( + updated.id, + { + level: "info", + message: `Dispatcher started subagent run ${run.runId}.`, + sessionKey, + runId: run.runId, + }, + { ownerId, token }, + ); + started.push({ + cardId: updated.id, + title: updated.title, + sessionKey, + runId: run.runId, + }); + } catch (error) { + const message = formatErrorMessage(error); + startFailures.push({ cardId: card.id, title: card.title, error: message }); + if (!token) { + continue; + } + try { + await params.store.block( + card.id, + { + ownerId, + token, + reason: `Dispatcher could not start worker: ${message}`, + }, + { ownerId, token }, + ); + } catch { + // Leave the original start failure visible; dispatch will diagnose stale claims later. + } + } + } + + return { + ...dispatch, + started, + startFailures, + count: dispatch.count + started.length + startFailures.length, + }; +} diff --git a/extensions/workboard/src/gateway.ts b/extensions/workboard/src/gateway.ts index b1b4e18221d..e59945f8e24 100644 --- a/extensions/workboard/src/gateway.ts +++ b/extensions/workboard/src/gateway.ts @@ -1,5 +1,6 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { OpenClawPluginApi } from "../api.js"; +import { dispatchAndStartWorkboardCards } from "./dispatcher.js"; import { WorkboardStore } from "./store.js"; import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js"; @@ -383,7 +384,10 @@ export function registerWorkboardGatewayMethods(params: { "workboard.cards.dispatch", async ({ respond }) => { try { - const result = await store.dispatch(); + const result = await dispatchAndStartWorkboardCards({ + store, + subagent: api.runtime.subagent, + }); respond(true, { ...result, promoted: result.promoted.map(redactClaimToken),