From 301208face8082bb79d3a4469d8c3f03e52e8803 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:26:49 -0700 Subject: [PATCH] fix(driver): an unloaded mode downgrades instead of hanging the dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review/refactor/devsecops are not Bob built-ins — 2.0.3 ships agent/plan/ask — they come from the workspace's .bob/custom_modes.yaml that init-project-board.mjs installs. In a project without that file the slug does not resolve, and Bob reports that by doing nothing: startTask -> handleInputMessage posts "Invalid mode used." to the webview and returns, after openTask has already created the task row. The driver correlated a row that never ran, so updated_at never passed created_at, the completion watch never settled, and the dispatch burned its full wall clock to report a bare timeout. Resolve the slug against the workspace's modes before dispatching: on a miss, name the file to add and run the turn in a fallback mode rather than stalling. The fallback keeps the mode's safety profile — a read-only slug lands on ask, the only built-in with no edit group, not on write-capable agent, so a review that lost its custom mode still cannot rewrite the code it was sent to inspect. The comment claiming Bob throws `Mode with id "" not found` on an unknown slug described resolveMode(), which startTask never calls; corrected alongside. Verified against the 2.0.3 bundle and a live store: startTask, the tasks/messages/ task_pending_approvals schema (newest migration is still 010_pending_approvals), the active->running->active lifecycle, and the settings.json auto-approve keys are all unchanged. costs gained a contextTokens field the existing parser ignores. --- CHANGELOG.md | 22 +++++++ claude-plugin/.claude-plugin/plugin.json | 2 +- extension/package.json | 2 +- lhm.plugin.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/bob2-driver.test.ts | 50 ++++++++++++++-- src/bob2-driver.ts | 20 +++++-- src/bob2-modes.test.ts | Bin 0 -> 4169 bytes src/bob2-modes.ts | 70 +++++++++++++++++++++++ 10 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 src/bob2-modes.test.ts create mode 100644 src/bob2-modes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af1842..5e814d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are [SemVer](https://semver.org/). +## [2.3.1] — 2026-08-14 — Bob 2.0.3: mode preflight + +Verified against the 2.0.3 bundle and a live store: `startTask`, the tasks/messages/`task_pending_approvals` +schema (newest migration is still 2.0.2's `010_pending_approvals`), the active→running→active lifecycle, and +the `settings.json` auto-approve keys are all unchanged, so 2.0.3 needs no driver changes. The `costs` JSON +gained a `contextTokens` field, which the existing parser ignores. The fix below is a pre-existing gap 2.0.3 +did not cause. + +### Fixed + +- **A mode the workspace can't load no longer hangs the dispatch.** `review`/`refactor`/`devsecops` are not + Bob built-ins — 2.0.3 ships `agent`/`plan`/`ask` — they come from the workspace's `.bob/custom_modes.yaml` + that `init-project-board.mjs` installs. In a project without that file the slug doesn't resolve, and Bob + reports it by doing nothing: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview + and returns, after `openTask` has already created the task row. The driver correlated a row that never ran, + so `updated_at` never passed `created_at`, the completion watch never settled, and the dispatch burned its + full wall clock to report a bare `timeout`. It now resolves the slug against the workspace's modes first, + names the file to add, and runs the turn in a fallback mode rather than stalling. The fallback preserves + the mode's safety profile: a read-only slug lands on `ask` — the only built-in with no `edit` group — not + on write-capable `agent`, so a review that lost its custom mode still cannot rewrite the code it was sent + to inspect. + ## [2.3.0] — 2026-08-07 — Bob 2.0.2: trust preflight + approval-wedge fast-abort Verified against the 2.0.2 bundle: every contract the driver relies on (startTask, tasks/messages schema, diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 8dcffbe..d36b6d2 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "bob-companion", "displayName": "Bob Companion", "description": "Use Claude Code from any repo as the foreman and worker for the IBM Bob task board: provision, route, triage, and drain tasks Bob shares. Ships a self-contained MCP server.", - "version": "2.3.0", + "version": "2.3.1", "author": { "name": "Joshua Gilbert" }, diff --git a/extension/package.json b/extension/package.json index d142a3d..0eca0c1 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "bob-tasks", "displayName": "Bob Tasks", "description": "Auto-dispatch queued tasks to IBM Bob, with mode routing, a safety gate, defer-while-chatting, and native notifications.", - "version": "2.3.0", + "version": "2.3.1", "publisher": "local", "license": "Apache-2.0", "engines": { diff --git a/lhm.plugin.json b/lhm.plugin.json index 0a50a9a..4b1fdb2 100644 --- a/lhm.plugin.json +++ b/lhm.plugin.json @@ -1,7 +1,7 @@ { "identifier": "pounceai-bob-control", "name": "Bob Control", - "version": "2.3.0", + "version": "2.3.1", "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board. The board path can be configured via environment variables such as BOB_TASKS_DB, BOB_TASKS_PORTABLE, or BOB_TASKS_WORKTREE_SHARED.", "author": "PounceAI", "authorUrl": "https://github.com/PounceAI", diff --git a/package-lock.json b/package-lock.json index ab64295..00f90e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bob-control", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bob-control", - "version": "2.3.0", + "version": "2.3.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index a59036e..bb8ce60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pounceai/bob-control", - "version": "2.3.0", + "version": "2.3.1", "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board.", "author": "Joshua Gilbert", "license": "Apache-2.0", diff --git a/src/bob2-driver.test.ts b/src/bob2-driver.test.ts index 2cb1671..fbc552d 100644 --- a/src/bob2-driver.test.ts +++ b/src/bob2-driver.test.ts @@ -353,10 +353,11 @@ test("dispatch surfaces a post-start store-open fault (cold start, then the db w test("toBob2Mode remaps removed 1.x built-ins to agent; passes through 2.0 built-ins and custom modes", () => { // removed 1.x built-ins our router still emits → agent (Bob 2.0 throws "Mode not found" on these) for (const m of ["code", "advanced", "orchestrator"]) assert.equal(toBob2Mode(m), "agent"); - // 2.0 built-ins pass through - for (const m of ["agent", "ask", "plan", "review"]) assert.equal(toBob2Mode(m), m); - // CUSTOM modes pass through unchanged — Bob 2.0 loads custom_modes.yaml and resolves them - for (const m of ["refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m); + // 2.0 built-ins pass through (agent/plan/ask are Bob 2.0.3's DEFAULT_MODES) + for (const m of ["agent", "ask", "plan"]) assert.equal(toBob2Mode(m), m); + // CUSTOM modes pass through unchanged — incl. review, which is OURS (workspace .bob/custom_modes.yaml), + // not a Bob built-in. Whether the workspace actually loads them is the preflight's job, not this map's. + for (const m of ["review", "refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m); // no mode → Bob's default coding mode assert.equal(toBob2Mode(undefined), "agent"); assert.equal(toBob2Mode(null), "agent"); @@ -384,6 +385,47 @@ test("dispatch forwards the WorkspaceFolder OBJECT (not the string) and the tran assert.equal(seenMode, "agent"); // board "code" → Bob 2.0 "agent" }); +test("a mode the workspace can't load warns and downgrades — the dispatch still runs, it does not fail", async () => { + const { store, seedRoot, bump } = makeStore(); + let seenMode: unknown = "UNSET"; + let id = ""; + const warnings: string[] = []; + const host = makeHost({ + folder: DIR, // no .bob/custom_modes.yaml under it, so `review` is not loaded + startTask: (o) => { + seenMode = o.mode; + id = seedRoot("running"); + }, + }); + const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast }); + setTimeout(() => bump(id, "active"), 15); + const res = await driver.dispatch({ text: "review the diff", mode: "review" }); + assert.equal(res.status, "completed"); // the point: a missing mode must not fail the dispatch + assert.equal(seenMode, "ask"); // read-only stays read-only — never the write-capable agent + assert.equal(warnings.length, 1); + assert.match(warnings[0], /mode 'review' is not loaded/); + assert.match(warnings[0], /custom_modes\.yaml/); +}); + +test("a mode Bob can load dispatches as itself and prints nothing", async () => { + const { store, seedRoot, bump } = makeStore(); + let seenMode: unknown = "UNSET"; + let id = ""; + const warnings: string[] = []; + const host = makeHost({ + folder: DIR, + startTask: (o) => { + seenMode = o.mode; + id = seedRoot("running"); + }, + }); + const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast }); + setTimeout(() => bump(id, "active"), 15); + await driver.dispatch({ text: "explain this", mode: "ask" }); // a 2.0 built-in — always loadable + assert.equal(seenMode, "ask"); + assert.deepEqual(warnings, []); +}); + // ── defer-while-chatting: externalActivity (driver wiring over foreignActivity) ───────────────────── test("externalActivity is true while a foreign chat is running in our workspace", async () => { diff --git a/src/bob2-driver.ts b/src/bob2-driver.ts index 23df100..666693c 100644 --- a/src/bob2-driver.ts +++ b/src/bob2-driver.ts @@ -14,6 +14,7 @@ import { type Bob2TaskRow, } from "./bob2-taskstore.js"; import { writeAutoApprove } from "./bob2-config.js"; +import { resolveAvailableMode } from "./bob2-modes.js"; import { producesReviewFindings } from "./modes.js"; import { parseReviewFindings, type ReviewIssue } from "./review-findings.js"; @@ -79,6 +80,8 @@ export interface InProcessDriverOptions { /** How old a persisted pending approval must be before the watch reads it as a wedge (ms). The margin * keeps a just-raised request that Bob is still resolving from aborting a healthy turn. */ approvalWedgeMs?: number; + /** Where the mode-preflight notice goes. Default: `console.warn`. Injected by tests to assert on it. */ + warn?: (message: string) => void; } /** @@ -110,14 +113,16 @@ export function mapOutcome( return { ...base, status: "timeout" }; } -// Removed 1.x built-ins our auto-router still emits; Bob 2.0 throws `Mode with id "" not found` on them -// (built-ins are agent/ask/plan/review, coding = agent). +// Removed 1.x built-ins our auto-router still emits. Bob 2.0's built-ins are agent/plan/ask (DEFAULT_MODES), +// coding = agent, so these three have nothing to resolve to. const BOB2_REMOVED_BUILTIN_MODES: Record = { code: "agent", advanced: "agent", orchestrator: "agent" }; /** - * Board slug → a mode Bob 2.0 resolves. Only the removed 1.x built-ins are rewritten; the 2.0 built-ins - * and every custom mode pass through, since Bob 2.0 loads custom_modes.yaml (so review/refactor/devsecops - * dispatch as themselves; an unregistered slug then surfaces Bob's clean "Mode not found"). No mode → agent. + * Board slug → the slug we ask Bob for. Only the removed 1.x built-ins are rewritten; everything else passes + * through, including review/refactor/devsecops — those are OURS, defined in the workspace's + * .bob/custom_modes.yaml, not Bob built-ins. Whether the open workspace actually loads them is a separate + * question, answered by resolveAvailableMode at dispatch (an unloaded slug does NOT error — Bob just never + * runs the turn), so this map stays a pure rename. No mode → agent. */ export function toBob2Mode(mode: string | undefined | null): string { if (!mode) return "agent"; @@ -268,12 +273,15 @@ export class InProcessDriver implements BobDriver { return fail(`task store: ${(e as Error).message}`); } const snapshot = store ? store.snapshotRoots() : { ids: new Set(), sinceMs: 0 }; + // Mode preflight (see bob2-modes): an unloaded slug would hang the dispatch, so warn and downgrade. + const picked = resolveAvailableMode(toBob2Mode(opts.mode), dir); + if (picked.warning) (this.opts.warn ?? console.warn)(picked.warning); try { try { // workspaceFolder = the WorkspaceFolder object; mode a slug Bob resolves (see Bob2StartTask / toBob2Mode). await this.handle!.startTask({ content: opts.text, - mode: toBob2Mode(opts.mode), + mode: picked.mode, workspaceFolder: this.host.workspaceFolderObject() ?? undefined, }); } catch (e) { diff --git a/src/bob2-modes.test.ts b/src/bob2-modes.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3eb37dc92ce41210a68d44cfed775b3e467fb30 GIT binary patch literal 4169 zcmcgv&2Hm15boJeF%=ewoZ2$5M+aLVX;Wa)?q-qfb`P5%p(Wa4B9SUd*=ra9dg=od zeZqZ`&J6t{Id+=1X??Lok;CD9-+VJvWo4{~1$gN^+{4(K5+ZFfIi!*3sH4IR;he;Y zjqhP(;Jj67W8wnJyUfe7x|r*UA+*Ke=ioPB?K(Sx*h-jr|fkQYTgaXlD|q8N$vj)r<* zr8C7;9#4fT#Hf%oy51rgjLhg|zr-<@+|pK^!2p*`m7EQ%9E;Q&JILhJ$<$O1gq2`w zOPf$=eJ){U?47GbDi0haXl)SuxehkxqAcLcuYZ6x9!|^%Mztz@Uul=%R)>dhyfFZD z_*7UsS9-#>Q=_#c7tl7_gE0miVxx;WPjGZ%D24aZf;V9-wW5-?o510qGp=>&mC>7B zJ}RG63|!2Mhd_|)$)~IC>Jb(l0NffWL>|3rQXnQ2;{a6L?LmYm4&m!Z-R==CTvH@W zMGH8K0u$l%1;#eT_^GKJg10py!HZgCiqQ%%UE;=SwDKUbXp-kHzwoh!mpX!Pco=|> zn3ST5W7maO-@*+*pGSxAV&U#DHEpFuiSBIPC)U)JLqqhD7Fa2=&0A{9QfM5w=_VYG zDEPrAvC`L8Q{av|_ni)Ma2hoBV(oCJ3hMK0vG_jde65@G+1KVx$CQM$#9L*jh8& z4@yH+AIYc@%FQaRHdvrQ9kYC6gf*OfI1di=cwba%ue5Y-2m%mK7@5!EL;@;P)rFui zP6{&;MG%t`<&&3=3vnmWafQ-Ev|qx=3iE$rj`HmY^jiPtuy2(PRM0|Nj1USv=(;Ex ztRQrShFX&rP#xjuwX$x>W8%PDd#o*7iq?f<&jD*f7GMWm7<@DiuLtIOpu*7d;p&d zk77(%OspWzdXO1%lOD93V)vVij0v!WpIi8SS+?naHdJWi$jg@r7YTQ_rEl1Ar+FOC zv4Z^T9t;?UgD`#oD)a&YqCq=t_gS#DCUqgE9pJtPPh1xAI$Sy~6H;VcNa8xn74nv^ z4R}8gSqRedu=|{N*ljMYB}qb97VFy)oll$ p=|f!7#cEBuT_$(M6xXD90@}fuQueWWYBSS(JA2B0tMF1={3lWqW>o+H literal 0 HcmV?d00001 diff --git a/src/bob2-modes.ts b/src/bob2-modes.ts new file mode 100644 index 0000000..a7852f2 --- /dev/null +++ b/src/bob2-modes.ts @@ -0,0 +1,70 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { bob2HomeDir } from "./bob2-config.js"; +import { isReadOnlyMode } from "./modes.js"; + +// Will Bob 2.0 resolve this mode slug in this workspace? Worth asking up front because an unresolvable slug +// does NOT error: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview and returns, +// but `openTask` already created the task row — so the driver correlates a row that never runs, updated_at +// never passes created_at, and the dispatch burns its full wall clock to report a bare 'timeout'. +// Verified against Bob 2.0.3, 2026-08-14. + +/** Bob 2.0.3's DEFAULT_MODES — all that resolves without a custom_modes.yaml. */ +export const BOB2_BUILTIN_MODES: readonly string[] = ["agent", "plan", "ask"]; + +/** + * Custom-mode slugs available in `workspaceDir`, read the way Bob reads them: the workspace's + * `.bob/custom_modes.yaml` (where init-project-board.mjs puts our review/refactor/devsecops) plus the global + * `~/.bob/settings/custom_modes.yaml`. Scraped, not parsed — only slugs matter and the repo carries no YAML + * dep (init-project-board.mjs makes the same call). Over-reporting is the safe direction: a slug we wrongly + * accept just restores the hang above, while one we wrongly miss would downgrade a working mode. + */ +export function customModeSlugs(workspaceDir: string | null): Set { + const files = [ + workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : null, + join(bob2HomeDir(), "settings", "custom_modes.yaml"), + ]; + const slugs = new Set(); + for (const f of files) { + if (!f || !existsSync(f)) continue; + let raw: string; + try { + raw = readFileSync(f, "utf8"); + } catch { + continue; // unreadable config is not a dispatch failure — fall through to the built-ins + } + for (const m of raw.matchAll(/^\s*-?\s*slug:\s*["']?([A-Za-z0-9_-]+)["']?/gm)) slugs.add(m[1]); + } + return slugs; +} + +/** + * Where an unresolvable mode lands. Keyed on the safety profile rather than always `agent`: `ask` is the only + * built-in with no `edit` group, so a read-only slug that lost its custom mode still can't rewrite the code it + * was sent to inspect. It has no `execute` either — a degraded review can't run `git diff`, which is weaker + * but weak in the safe direction. + */ +export function fallbackMode(mode: string): string { + return isReadOnlyMode(mode) ? "ask" : "agent"; +} + +export interface ModeAvailability { + /** The slug to dispatch — `mode` itself, or its fallback. */ + mode: string; + /** Set only when a fallback was substituted. */ + warning?: string; +} + +/** Resolve `mode` for `workspaceDir`. Reports and downgrades, never rejects — a project missing its + * custom_modes.yaml still gets a usable turn. */ +export function resolveAvailableMode(mode: string, workspaceDir: string | null): ModeAvailability { + if (BOB2_BUILTIN_MODES.includes(mode) || customModeSlugs(workspaceDir).has(mode)) return { mode }; + const to = fallbackMode(mode); + const where = workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : "/.bob/custom_modes.yaml"; + return { + mode: to, + warning: + `bob2: mode '${mode}' is not loaded in this workspace — running as '${to}'. ` + + `Add it to ${where} (e.g. \`node tools/init-project-board.mjs \`).`, + }; +}