From 105de0124e227445bbba0af224866e52e5638520 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:06:47 +0000 Subject: [PATCH 01/12] feat(gastown): add core addBeadDependency & removeBeadDependency functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new exported functions to beads.ts for managing bead dependency edges after creation. Includes self-reference validation, cycle detection via DFS for 'blocks' dependencies, and protection against removing system-managed 'tracks' edges. Also adds 'dependency_added' and 'dependency_removed' to the BeadEventType enum for audit trail support. Closes #1100 (partial — layer 1/7) --- .../src/db/tables/bead-events.table.ts | 2 + cloudflare-gastown/src/dos/town/beads.ts | 136 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index e4314217fc..63607973b9 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -23,6 +23,8 @@ export const BeadEventType = z.enum([ 'review_queue_depth_alert', 'escalation_rate_spike', 'agent_restart_loop', + 'dependency_added', + 'dependency_removed', ]); export type BeadEventType = z.infer; diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index cf0f009346..f317a7ead1 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -13,6 +13,8 @@ import { } from '../../db/tables/bead-events.table'; import { bead_dependencies, + BeadDependencyRecord, + DependencyType, createTableBeadDependencies, getIndexesBeadDependencies, } from '../../db/tables/bead-dependencies.table'; @@ -908,3 +910,137 @@ export function getConvoyFeatureBranch(sql: SqlStorage, convoyId: string): strin if (rows.length === 0) return null; return z.object({ feature_branch: z.string().nullable() }).parse(rows[0]).feature_branch; } + +// ── Bead Dependency Editing ───────────────────────────────────────── + +/** + * Add a dependency edge between two beads. + * + * - Validates self-reference (`beadId !== dependsOnBeadId`) + * - Checks both beads exist + * - Runs cycle detection for 'blocks' dependencies (DFS from `dependsOnBeadId` + * — if you can reach `beadId`, adding the edge would create a cycle) + * - Uses `ON CONFLICT DO NOTHING` so duplicate adds are a no-op + */ +export function addBeadDependency( + sql: SqlStorage, + beadId: string, + dependsOnBeadId: string, + type: z.infer +): void { + if (beadId === dependsOnBeadId) { + throw new Error('A bead cannot depend on itself'); + } + + // Verify both beads exist + const existCheck = [ + ...query( + sql, + /* sql */ ` + SELECT ${beads.bead_id} + FROM ${beads} + WHERE ${beads.bead_id} IN (?, ?) + `, + [beadId, dependsOnBeadId] + ), + ]; + const foundIds = new Set( + z + .object({ bead_id: z.string() }) + .array() + .parse(existCheck) + .map(r => r.bead_id) + ); + if (!foundIds.has(beadId)) throw new Error(`Bead ${beadId} not found`); + if (!foundIds.has(dependsOnBeadId)) throw new Error(`Bead ${dependsOnBeadId} not found`); + + // Cycle detection for 'blocks' dependencies: DFS from dependsOnBeadId + // following existing 'blocks' edges. If we can reach beadId, adding + // this edge would create a cycle. + if (type === 'blocks') { + const adjacency = new Map(); + const edgeRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.bead_id}, ${bead_dependencies.depends_on_bead_id} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.dependency_type} = 'blocks' + `, + [] + ), + ]; + const edges = BeadDependencyRecord.pick({ bead_id: true, depends_on_bead_id: true }) + .array() + .parse(edgeRows); + for (const edge of edges) { + const neighbors = adjacency.get(edge.bead_id) ?? []; + neighbors.push(edge.depends_on_bead_id); + adjacency.set(edge.bead_id, neighbors); + } + + // DFS from dependsOnBeadId following the direction: bead_id → depends_on_bead_id + // We want to check: can dependsOnBeadId reach beadId through existing edges? + // The graph direction is: beadId depends on dependsOnBeadId. + // A cycle means: dependsOnBeadId already (transitively) depends on beadId. + // So we follow edges from dependsOnBeadId: check dependsOnBeadId's own + // depends_on edges to see if beadId is reachable. + const visited = new Set(); + const stack = [dependsOnBeadId]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current === beadId) { + throw new Error( + `Adding dependency would create a cycle: ${beadId} → ${dependsOnBeadId} → ... → ${beadId}` + ); + } + if (visited.has(current)) continue; + visited.add(current); + const neighbors = adjacency.get(current); + if (neighbors) { + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) stack.push(neighbor); + } + } + } + } + + query( + sql, + /* sql */ ` + INSERT INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, ?) + ON CONFLICT DO NOTHING + `, + [beadId, dependsOnBeadId, type] + ); +} + +/** + * Remove a dependency edge between two beads. + * Does NOT allow removing 'tracks' dependencies (system-managed convoy edges). + * Returns true if a row was actually deleted, false otherwise. + */ +export function removeBeadDependency( + sql: SqlStorage, + beadId: string, + dependsOnBeadId: string +): boolean { + const result = [ + ...query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} != 'tracks' + RETURNING ${bead_dependencies.bead_id} + `, + [beadId, dependsOnBeadId] + ), + ]; + return result.length > 0; +} From d2a814e20de5082790de751bed3b4b291df6d5c7 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:07:40 +0000 Subject: [PATCH 02/12] feat(gastown): add TownDO.addBeadDependency & removeBeadDependency methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the core dependency functions into TownDO with proper initialization, event logging, and alarm arming for unblocked bead dispatch. Closes #1100 (partial — layer 2/7) --- cloudflare-gastown/src/dos/Town.do.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index d73cfdded9..3d7a2e5121 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -833,6 +833,51 @@ export class TownDO extends DurableObject { return bead; } + // ── Bead Dependency Editing ────────────────────────────────────────── + + /** + * Add a dependency edge between two beads. + * Validates, detects cycles, and logs a bead event. + */ + async addBeadDependency( + beadId: string, + dependsOnBeadId: string, + type: 'blocks' | 'tracks' | 'parent-child' + ): Promise { + await this.ensureInitialized(); + beadOps.addBeadDependency(this.sql, beadId, dependsOnBeadId, type); + beadOps.logBeadEvent(this.sql, { + beadId, + agentId: null, + eventType: 'dependency_added', + metadata: { depends_on_bead_id: dependsOnBeadId, dependency_type: type }, + }); + } + + /** + * Remove a dependency edge between two beads. + * After removal, checks if any beads are now unblocked and arms the + * alarm so they get dispatched promptly. + */ + async removeBeadDependency(beadId: string, dependsOnBeadId: string): Promise { + await this.ensureInitialized(); + const deleted = beadOps.removeBeadDependency(this.sql, beadId, dependsOnBeadId); + if (deleted) { + beadOps.logBeadEvent(this.sql, { + beadId, + agentId: null, + eventType: 'dependency_removed', + metadata: { depends_on_bead_id: dependsOnBeadId }, + }); + // Check if removing this dependency unblocked any beads + const unblockedIds = beadOps.getNewlyUnblockedBeads(this.sql, dependsOnBeadId); + if (unblockedIds.length > 0) { + await this.ctx.storage.setAlarm(Date.now()); + } + } + return deleted; + } + /** * Force-reset an agent to idle, unhooking from its current bead if any. * Sets the bead status back to 'open' so it can be re-dispatched. From 5fb16b0f0bf16afd75ca8168660b7a670dbdac75 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:08:53 +0000 Subject: [PATCH 03/12] feat(gastown): add HTTP endpoints for bead dependency management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bead-dependencies.handler.ts with POST and DELETE handlers for adding and removing bead dependencies. Register routes in gastown.worker.ts under user auth middleware. POST /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies DELETE /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId Closes #1100 (partial — layer 3/7) --- cloudflare-gastown/src/gastown.worker.ts | 21 +++++++ .../src/handlers/bead-dependencies.handler.ts | 57 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 cloudflare-gastown/src/handlers/bead-dependencies.handler.ts diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 8282446cdf..1d9c65c0b2 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -54,6 +54,10 @@ import { } from './handlers/rig-review-queue.handler'; import { handleCreateEscalation } from './handlers/rig-escalations.handler'; import { handleResolveTriage } from './handlers/rig-triage.handler'; +import { + handleAddBeadDependency, + handleRemoveBeadDependency, +} from './handlers/bead-dependencies.handler'; import { handleListBeadEvents } from './handlers/rig-bead-events.handler'; import { handleListTownEvents } from './handlers/town-events.handler'; import { @@ -242,6 +246,23 @@ app.delete('/api/towns/:townId/rigs/:rigId/beads/:beadId', c => ) ); +// ── Bead Dependencies ────────────────────────────────────────────────── + +app.post('/api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies', c => + instrumented(c, 'POST /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies', () => + handleAddBeadDependency(c, c.req.param()) + ) +); +app.delete( + '/api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', + c => + instrumented( + c, + 'DELETE /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', + () => handleRemoveBeadDependency(c, c.req.param()) + ) +); + // ── Agents ────────────────────────────────────────────────────────────── app.post('/api/towns/:townId/rigs/:rigId/agents', c => diff --git a/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts new file mode 100644 index 0000000000..f600746435 --- /dev/null +++ b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts @@ -0,0 +1,57 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; +import { getTownDOStub } from '../dos/Town.do'; +import { resSuccess, resError } from '../util/res.util'; +import { parseJsonBody } from '../util/parse-json-body.util'; +import { DependencyType } from '../db/tables/bead-dependencies.table'; +import type { GastownEnv } from '../gastown.worker'; + +const AddDependencyBody = z.object({ + depends_on_bead_id: z.string().min(1), + dependency_type: DependencyType.optional().default('blocks'), +}); + +/** + * POST /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies + * Add a dependency edge between two beads. + */ +export async function handleAddBeadDependency( + c: Context, + params: { townId: string; beadId: string } +) { + const parsed = AddDependencyBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + const town = getTownDOStub(c.env, params.townId); + try { + await town.addBeadDependency( + params.beadId, + parsed.data.depends_on_bead_id, + parsed.data.dependency_type + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json(resError(message), 400); + } + + return c.json(resSuccess({ ok: true })); +} + +/** + * DELETE /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId + * Remove a dependency edge between two beads. + */ +export async function handleRemoveBeadDependency( + c: Context, + params: { townId: string; beadId: string; dependsOnBeadId: string } +) { + const town = getTownDOStub(c.env, params.townId); + const deleted = await town.removeBeadDependency(params.beadId, params.dependsOnBeadId); + + return c.json(resSuccess({ ok: true, deleted })); +} From d29b851883ea3f8284f9977bbf5b601493c5d37e Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:09:33 +0000 Subject: [PATCH 04/12] feat(gastown): add MayorGastownClient.addBeadDependency & removeBeadDependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the mayor tool client to the new bead dependency HTTP endpoints. Closes #1100 (partial — layer 4/7) --- cloudflare-gastown/container/plugin/client.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index cb728fde8f..6874763037 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -381,6 +381,35 @@ export class MayorGastownClient { ); } + async addBeadDependency(input: { + rig_id: string; + bead_id: string; + depends_on_bead_id: string; + dependency_type?: 'blocks' | 'tracks' | 'parent-child'; + }): Promise { + await this.request<{ ok: true }>( + `${this.baseUrl}/api/towns/${this.townId}/rigs/${input.rig_id}/beads/${input.bead_id}/dependencies`, + { + method: 'POST', + body: JSON.stringify({ + depends_on_bead_id: input.depends_on_bead_id, + dependency_type: input.dependency_type, + }), + } + ); + } + + async removeBeadDependency(input: { + rig_id: string; + bead_id: string; + depends_on_bead_id: string; + }): Promise { + await this.request<{ ok: true; deleted: boolean }>( + `${this.baseUrl}/api/towns/${this.townId}/rigs/${input.rig_id}/beads/${input.bead_id}/dependencies/${input.depends_on_bead_id}`, + { method: 'DELETE' } + ); + } + async listConvoys(): Promise { return this.request(this.mayorPath('/convoys')); } From 1468629fea3fbe466795ac85dba5cf8ca75ed9f3 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:10:39 +0000 Subject: [PATCH 05/12] feat(gastown): add gt_bead_add_dependency, gt_bead_remove_dependency tools and gt_sling depends_on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new mayor tools for managing bead dependencies after creation: - gt_bead_add_dependency: add a blocks/parent-child dependency edge - gt_bead_remove_dependency: remove a dependency edge (auto-dispatches if unblocked) Also add optional depends_on parameter to gt_sling for declaring dependencies at sling time. Closes #1100 (partial — layers 5-6/7) --- .../container/plugin/mayor-tools.ts | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 1cec0a37cf..6c1ab2e396 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -67,6 +67,12 @@ export function createMayorTools(client: MayorGastownClient) { .string() .describe('JSON-encoded metadata object for additional context') .optional(), + depends_on: tool.schema + .array(tool.schema.string()) + .describe( + 'Optional list of bead IDs this task depends on. The new bead will not be dispatched until all listed beads are closed.' + ) + .optional(), }, async execute(args) { const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; @@ -76,13 +82,30 @@ export function createMayorTools(client: MayorGastownClient) { body: args.body, metadata, }); - return [ + + // Add dependency edges if depends_on was provided + if (args.depends_on && args.depends_on.length > 0) { + for (const depBeadId of args.depends_on) { + await client.addBeadDependency({ + rig_id: args.rig_id, + bead_id: result.bead.bead_id, + depends_on_bead_id: depBeadId, + dependency_type: 'blocks', + }); + } + } + + const lines = [ `Task slung successfully.`, `Bead: ${result.bead.bead_id} — "${result.bead.title}"`, `Assigned to: ${result.agent.name} (${result.agent.role}, id: ${result.agent.id})`, `Status: ${result.bead.status}`, - `The polecat will be dispatched automatically by the alarm scheduler.`, - ].join('\n'); + ]; + if (args.depends_on && args.depends_on.length > 0) { + lines.push(`Dependencies: blocked by ${args.depends_on.length} bead(s)`); + } + lines.push(`The polecat will be dispatched automatically by the alarm scheduler.`); + return lines.join('\n'); }, }), @@ -475,5 +498,52 @@ export function createMayorTools(client: MayorGastownClient) { return `Nudge queued: ${result.nudge_id} (mode: ${args.mode ?? 'wait-idle'})`; }, }), + + gt_bead_add_dependency: tool({ + description: + 'Add a dependency between two beads. The bead at bead_id will be blocked by depends_on_bead_id — ' + + 'it will not be dispatched until the dependency is closed.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'), + bead_id: tool.schema.string().describe('The UUID of the bead that should be blocked'), + depends_on_bead_id: tool.schema + .string() + .describe('The UUID of the bead that must close first'), + dependency_type: tool.schema + .enum(['blocks', 'parent-child']) + .describe('Type of dependency (default: blocks)') + .optional(), + }, + async execute(args) { + await client.addBeadDependency({ + rig_id: args.rig_id, + bead_id: args.bead_id, + depends_on_bead_id: args.depends_on_bead_id, + dependency_type: args.dependency_type ?? 'blocks', + }); + return `Dependency added: bead ${args.bead_id} now depends on ${args.depends_on_bead_id} (type: ${args.dependency_type ?? 'blocks'}).`; + }, + }), + + gt_bead_remove_dependency: tool({ + description: + 'Remove a dependency between two beads. If removing the dependency unblocks the bead, ' + + 'it will be dispatched automatically.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'), + bead_id: tool.schema.string().describe('The UUID of the dependent bead'), + depends_on_bead_id: tool.schema + .string() + .describe('The UUID of the bead it currently depends on'), + }, + async execute(args) { + await client.removeBeadDependency({ + rig_id: args.rig_id, + bead_id: args.bead_id, + depends_on_bead_id: args.depends_on_bead_id, + }); + return `Dependency removed: bead ${args.bead_id} no longer depends on ${args.depends_on_bead_id}. If this was the last blocker, the bead will be dispatched automatically.`; + }, + }), }; } From 2798e2a63bbb26abff5819241f8b3223ab958a9e Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:12:05 +0000 Subject: [PATCH 06/12] docs(gastown): document new dependency tools in mayor system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gt_bead_add_dependency and gt_bead_remove_dependency to the Surgical Editing section of the mayor's system prompt. Update gt_sling description to mention the new optional depends_on parameter. Closes #1100 (partial — layer 7/7) --- cloudflare-gastown/src/prompts/mayor-system.prompt.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index cf98c598b6..98e4de4b9c 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -25,7 +25,7 @@ Your #1 purpose is to turn user requests into actionable work items. Every time You have these tools for cross-rig coordination: -- **gt_sling** — Delegate a single task to a polecat in a specific rig. Use for one-off tasks. +- **gt_sling** — Delegate a single task to a polecat in a specific rig. Use for one-off tasks. Accepts an optional \`depends_on\` array of bead IDs — the new bead will not be dispatched until all listed beads are closed. - **gt_sling_batch** — YOUR MOST IMPORTANT TOOL. Sling multiple beads as a tracked convoy. Use this when breaking work into parallel sub-tasks. Creates all beads at once, groups them into a convoy for progress tracking, and dispatches polecats automatically. Accepts an optional \`merge_mode\`: - **"review-then-land"** (default): Each bead is reviewed by the refinery and merged into the convoy's feature branch. Only at the very end does a PR or merge to main occur. Best for tightly coupled tasks that build on each other. - **"review-and-merge"**: Each bead goes through the full review + merge/PR cycle independently. Best for loosely coupled tasks where each can land on its own. @@ -215,6 +215,8 @@ You can directly edit town state when things go wrong: - **gt_convoy_close** to force-close a stuck convoy - **gt_convoy_update** to edit convoy merge_mode or feature_branch - **gt_bead_delete** to remove beads that shouldn't exist +- **gt_bead_add_dependency** to add a dependency between beads (the bead at bead_id will be blocked by depends_on_bead_id) +- **gt_bead_remove_dependency** to remove a dependency between beads (if this unblocks the bead, it will be dispatched automatically) - **gt_escalation_acknowledge** to acknowledge escalations Use these tools when the user reports stuck state, when you detect problems during delegation, or when you need to clean up after failures. You are the town coordinator — you have full authority over the control plane. From d5a3bdc39cde6bdfddf916673de082ee70bb538a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:19:21 +0000 Subject: [PATCH 07/12] fix(gastown): fix lint errors in bead dependency code - Use literal union type instead of z.infer to satisfy consistent-type-imports rule - Replace non-null assertion with explicit undefined check in DFS loop - Fix prettier formatting in gastown.worker.ts --- cloudflare-gastown/src/dos/town/beads.ts | 6 +++--- cloudflare-gastown/src/gastown.worker.ts | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index f317a7ead1..f41e2d4052 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -14,7 +14,6 @@ import { import { bead_dependencies, BeadDependencyRecord, - DependencyType, createTableBeadDependencies, getIndexesBeadDependencies, } from '../../db/tables/bead-dependencies.table'; @@ -926,7 +925,7 @@ export function addBeadDependency( sql: SqlStorage, beadId: string, dependsOnBeadId: string, - type: z.infer + type: 'blocks' | 'tracks' | 'parent-child' ): void { if (beadId === dependsOnBeadId) { throw new Error('A bead cannot depend on itself'); @@ -988,7 +987,8 @@ export function addBeadDependency( const visited = new Set(); const stack = [dependsOnBeadId]; while (stack.length > 0) { - const current = stack.pop()!; + const current = stack.pop(); + if (current === undefined) break; if (current === beadId) { throw new Error( `Adding dependency would create a cycle: ${beadId} → ${dependsOnBeadId} → ... → ${beadId}` diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 1d9c65c0b2..4e36f52fcf 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -253,14 +253,12 @@ app.post('/api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies', c => handleAddBeadDependency(c, c.req.param()) ) ); -app.delete( - '/api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', - c => - instrumented( - c, - 'DELETE /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', - () => handleRemoveBeadDependency(c, c.req.param()) - ) +app.delete('/api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', c => + instrumented( + c, + 'DELETE /api/towns/:townId/rigs/:rigId/beads/:beadId/dependencies/:dependsOnBeadId', + () => handleRemoveBeadDependency(c, c.req.param()) + ) ); // ── Agents ────────────────────────────────────────────────────────────── From ca5a4f17a606b8d42deba72f40a438227db4a889 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 18:57:31 +0000 Subject: [PATCH 08/12] fix(gastown): fix alarm logic and add rigId validation in bead dependency handlers - removeBeadDependency now correctly checks if beadId itself is unblocked (using hasUnresolvedBlockers) rather than calling getNewlyUnblockedBeads with dependsOnBeadId, which was designed for the bead-close path - handleAddBeadDependency and handleRemoveBeadDependency now validate that beadId belongs to the rigId in the URL path, matching handleDeleteBead --- cloudflare-gastown/src/dos/Town.do.ts | 6 +++--- .../src/handlers/bead-dependencies.handler.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 3d7a2e5121..4f40e61b8f 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -869,9 +869,9 @@ export class TownDO extends DurableObject { eventType: 'dependency_removed', metadata: { depends_on_bead_id: dependsOnBeadId }, }); - // Check if removing this dependency unblocked any beads - const unblockedIds = beadOps.getNewlyUnblockedBeads(this.sql, dependsOnBeadId); - if (unblockedIds.length > 0) { + // If beadId has no remaining unresolved blockers, arm the alarm so + // it gets dispatched promptly. + if (!beadOps.hasUnresolvedBlockers(this.sql, beadId)) { await this.ctx.storage.setAlarm(Date.now()); } } diff --git a/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts index f600746435..12b263b0ea 100644 --- a/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts +++ b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts @@ -17,7 +17,7 @@ const AddDependencyBody = z.object({ */ export async function handleAddBeadDependency( c: Context, - params: { townId: string; beadId: string } + params: { townId: string; rigId: string; beadId: string } ) { const parsed = AddDependencyBody.safeParse(await parseJsonBody(c)); if (!parsed.success) { @@ -28,6 +28,9 @@ export async function handleAddBeadDependency( } const town = getTownDOStub(c.env, params.townId); + const bead = await town.getBeadAsync(params.beadId); + if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); + try { await town.addBeadDependency( params.beadId, @@ -48,9 +51,12 @@ export async function handleAddBeadDependency( */ export async function handleRemoveBeadDependency( c: Context, - params: { townId: string; beadId: string; dependsOnBeadId: string } + params: { townId: string; rigId: string; beadId: string; dependsOnBeadId: string } ) { const town = getTownDOStub(c.env, params.townId); + const bead = await town.getBeadAsync(params.beadId); + if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); + const deleted = await town.removeBeadDependency(params.beadId, params.dependsOnBeadId); return c.json(resSuccess({ ok: true, deleted })); From 7140af20fb8775468ca598d06d100c0816c42c5b Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 19:33:30 +0000 Subject: [PATCH 09/12] fix(gastown): address PR review comments on bead dependency editing - Fix Comment 1: Add dependsOn parameter to slingBead() so dependency rows are inserted atomically before dispatch alarm is armed, preventing a new bead from being dispatched before its blockers are registered. - Fix Comment 2: Restrict AddDependencyBody to EditableDependencyType (blocks, parent-child) so callers cannot create system-managed 'tracks' edges via the public API. - Fix Comment 3: Validate that depends_on_bead_id belongs to the same rig as the primary bead in handleAddBeadDependency, preventing cross-rig dependency injection. - Fix Comment 4: Same cross-rig validation for dependsOnBeadId in handleRemoveBeadDependency. --- cloudflare-gastown/container/plugin/client.ts | 1 + .../container/plugin/mayor-tools.ts | 15 +++------------ cloudflare-gastown/src/dos/Town.do.ts | 13 ++++++++++++- .../src/handlers/bead-dependencies.handler.ts | 17 +++++++++++++++-- .../src/handlers/mayor-tools.handler.ts | 6 +++++- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 6874763037..9cb2bf252a 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -300,6 +300,7 @@ export class MayorGastownClient { title: string; body?: string; metadata?: Record; + depends_on?: string[]; }): Promise { return this.request(this.mayorPath('/sling'), { method: 'POST', diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 6c1ab2e396..0f8c6b28a9 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -76,25 +76,16 @@ export function createMayorTools(client: MayorGastownClient) { }, async execute(args) { const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; + // Pass depends_on directly to client.sling() so TownDO.slingBead() + // inserts the dependency rows atomically before arming dispatch. const result = await client.sling({ rig_id: args.rig_id, title: args.title, body: args.body, metadata, + depends_on: args.depends_on, }); - // Add dependency edges if depends_on was provided - if (args.depends_on && args.depends_on.length > 0) { - for (const depBeadId of args.depends_on) { - await client.addBeadDependency({ - rig_id: args.rig_id, - bead_id: result.bead.bead_id, - depends_on_bead_id: depBeadId, - dependency_type: 'blocks', - }); - } - } - const lines = [ `Task slung successfully.`, `Bead: ${result.bead.bead_id} — "${result.bead.title}"`, diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 4f40e61b8f..497cbae9e9 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1676,6 +1676,7 @@ export class TownDO extends DurableObject { body?: string; priority?: string; metadata?: Record; + dependsOn?: string[]; }): Promise<{ bead: Bead; agent: Agent }> { await this.ensureInitialized(); @@ -1688,6 +1689,15 @@ export class TownDO extends DurableObject { metadata: input.metadata, }); + // Insert dependency rows before hooking/dispatching so the bead's + // blocker set is complete before any agent can start work on it. + // This is atomic within the DO's synchronous SQLite transaction. + if (input.dependsOn && input.dependsOn.length > 0) { + for (const depBeadId of input.dependsOn) { + beadOps.addBeadDependency(this.sql, createdBead.bead_id, depBeadId, 'blocks'); + } + } + const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); agents.hookBead(this.sql, agent.id, createdBead.bead_id); @@ -1696,7 +1706,8 @@ export class TownDO extends DurableObject { const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; // Fire-and-forget dispatch so the sling call returns immediately. - // The alarm loop retries if this fails. + // The alarm loop retries if this fails. If depends_on was set, the bead + // will be blocked and dispatchAgent will hold it until blockers close. this.dispatchAgent(hookedAgent, bead).catch(err => console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) ); diff --git a/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts index 12b263b0ea..f53d0bb9f2 100644 --- a/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts +++ b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts @@ -3,12 +3,15 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { DependencyType } from '../db/tables/bead-dependencies.table'; import type { GastownEnv } from '../gastown.worker'; +// Only allow user-editable dependency types. 'tracks' is system-managed +// (created by slingConvoy) and must not be creatable via the public API. +const EditableDependencyType = z.enum(['blocks', 'parent-child']); + const AddDependencyBody = z.object({ depends_on_bead_id: z.string().min(1), - dependency_type: DependencyType.optional().default('blocks'), + dependency_type: EditableDependencyType.optional().default('blocks'), }); /** @@ -31,6 +34,11 @@ export async function handleAddBeadDependency( const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); + const depBead = await town.getBeadAsync(parsed.data.depends_on_bead_id); + if (!depBead || depBead.rig_id !== params.rigId) { + return c.json(resError('Dependency bead not found in this rig'), 404); + } + try { await town.addBeadDependency( params.beadId, @@ -57,6 +65,11 @@ export async function handleRemoveBeadDependency( const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); + const depBead = await town.getBeadAsync(params.dependsOnBeadId); + if (!depBead || depBead.rig_id !== params.rigId) { + return c.json(resError('Dependency bead not found in this rig'), 404); + } + const deleted = await town.removeBeadDependency(params.beadId, params.dependsOnBeadId); return c.json(resSuccess({ ok: true, deleted })); diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index 6f778d45d0..cede40ab0f 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -24,6 +24,7 @@ const MayorSlingBody = z.object({ title: z.string().min(1), body: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), + depends_on: z.array(z.string().min(1)).optional(), }); const MayorSlingBatchBody = z @@ -152,7 +153,10 @@ export async function handleMayorSling(c: Context, params: { townId: const town = getTownDOStub(c.env, params.townId); const result = await town.slingBead({ rigId: parsed.data.rig_id, - ...parsed.data, + title: parsed.data.title, + body: parsed.data.body, + metadata: parsed.data.metadata, + dependsOn: parsed.data.depends_on, }); console.log( From 3e3c6fae2fc6cb3cc1603fb8372c613e050c5a78 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 19:48:59 +0000 Subject: [PATCH 10/12] fix(gastown): guard slingBead() dispatch with hasUnresolvedBlockers check Mirror the slingConvoy() pattern: only call dispatchAgent() when the new bead has no unresolved blockers. Previously, slingBead() with depends_on would insert the dependency rows atomically but then immediately dispatch the agent unconditionally, allowing blocked beads to start work before their prerequisites close. --- cloudflare-gastown/src/dos/Town.do.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 497cbae9e9..05c7110ba6 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1705,12 +1705,18 @@ export class TownDO extends DurableObject { const bead = beadOps.getBead(this.sql, createdBead.bead_id) ?? createdBead; const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; - // Fire-and-forget dispatch so the sling call returns immediately. - // The alarm loop retries if this fails. If depends_on was set, the bead - // will be blocked and dispatchAgent will hold it until blockers close. - this.dispatchAgent(hookedAgent, bead).catch(err => - console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) - ); + // Only dispatch if the bead has no unresolved blockers. Mirror the + // slingConvoy() guard so a bead with depends_on is not started before + // its blockers close. + if (!beadOps.hasUnresolvedBlockers(this.sql, bead.bead_id)) { + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) + ); + } else { + console.log( + `${TOWN_LOG} slingBead: bead=${bead.bead_id} blocked, deferring dispatch until deps close` + ); + } await this.armAlarmIfNeeded(); return { bead, agent: hookedAgent }; } From d36311a7667ae0f969ce3b3aa9ac116c86bfaa9b Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 16:43:20 -0500 Subject: [PATCH 11/12] feat(gastown): allow mayor to add/remove beads to convoys Add convoy_id support to gt_sling and gt_bead_update mayor tools so the mayor can sling new beads directly into a convoy and move existing beads in/out of convoys. Introduces addBeadToConvoy/removeBeadFromConvoy helpers that manage the tracks dependency, metadata, and convoy counters atomically. --- cloudflare-gastown/container/plugin/client.ts | 2 + .../container/plugin/mayor-tools.ts | 22 ++- cloudflare-gastown/src/dos/Town.do.ts | 27 ++++ cloudflare-gastown/src/dos/town/beads.ts | 147 ++++++++++++++++++ .../src/handlers/mayor-tools.handler.ts | 25 ++- 5 files changed, 220 insertions(+), 3 deletions(-) diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 9cb2bf252a..3f4ed8d1ea 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -301,6 +301,7 @@ export class MayorGastownClient { body?: string; metadata?: Record; depends_on?: string[]; + convoy_id?: string; }): Promise { return this.request(this.mayorPath('/sling'), { method: 'POST', @@ -428,6 +429,7 @@ export class MayorGastownClient { status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed'; priority?: 'low' | 'medium' | 'high' | 'critical'; labels?: string[]; + convoy_id?: string | null; } ): Promise { return this.request(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), { diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 0f8c6b28a9..3b73f8b4b9 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -73,6 +73,12 @@ export function createMayorTools(client: MayorGastownClient) { 'Optional list of bead IDs this task depends on. The new bead will not be dispatched until all listed beads are closed.' ) .optional(), + convoy_id: tool.schema + .string() + .describe( + 'Optional convoy ID to add this bead to. The bead will be tracked by the convoy and included in its progress.' + ) + .optional(), }, async execute(args) { const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; @@ -84,6 +90,7 @@ export function createMayorTools(client: MayorGastownClient) { body: args.body, metadata, depends_on: args.depends_on, + convoy_id: args.convoy_id, }); const lines = [ @@ -95,6 +102,9 @@ export function createMayorTools(client: MayorGastownClient) { if (args.depends_on && args.depends_on.length > 0) { lines.push(`Dependencies: blocked by ${args.depends_on.length} bead(s)`); } + if (args.convoy_id) { + lines.push(`Convoy: added to ${args.convoy_id}`); + } lines.push(`The polecat will be dispatched automatically by the alarm scheduler.`); return lines.join('\n'); }, @@ -318,7 +328,9 @@ export function createMayorTools(client: MayorGastownClient) { }), gt_bead_update: tool({ - description: "Edit a bead's status, title, body, priority, or labels.", + description: + "Edit a bead's status, title, body, priority, labels, or convoy membership. " + + 'Set convoy_id to add the bead to a convoy, or set it to null/empty to remove it.', args: { rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'), bead_id: tool.schema.string().describe('The UUID of the bead to update'), @@ -336,6 +348,13 @@ export function createMayorTools(client: MayorGastownClient) { .array(tool.schema.string()) .describe('Replacement labels array for the bead') .optional(), + convoy_id: tool.schema + .string() + .describe( + 'Set to a convoy UUID to add this bead to that convoy. ' + + 'Set to an empty string to remove the bead from its current convoy.' + ) + .optional(), }, async execute(args) { const bead = await client.updateBead(args.rig_id, args.bead_id, { @@ -344,6 +363,7 @@ export function createMayorTools(client: MayorGastownClient) { status: args.status, priority: args.priority, labels: args.labels, + convoy_id: args.convoy_id === '' ? null : args.convoy_id, }); return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`; }, diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 05c7110ba6..f68ee09433 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -878,6 +878,27 @@ export class TownDO extends DurableObject { return deleted; } + // ── Convoy Membership ──────────────────────────────────────────────── + + /** + * Add a bead to an existing convoy. Creates the 'tracks' dependency, + * merges convoy metadata into the bead, and increments total_beads. + */ + async addBeadToConvoy(beadId: string, convoyId: string): Promise { + await this.ensureInitialized(); + beadOps.addBeadToConvoy(this.sql, beadId, convoyId); + } + + /** + * Remove a bead from its convoy. Deletes the 'tracks' dependency, + * strips convoy metadata, and decrements total_beads. + * Returns the convoy ID the bead was removed from, or null if not in a convoy. + */ + async removeBeadFromConvoy(beadId: string): Promise { + await this.ensureInitialized(); + return beadOps.removeBeadFromConvoy(this.sql, beadId); + } + /** * Force-reset an agent to idle, unhooking from its current bead if any. * Sets the bead status back to 'open' so it can be re-dispatched. @@ -1677,6 +1698,7 @@ export class TownDO extends DurableObject { priority?: string; metadata?: Record; dependsOn?: string[]; + convoyId?: string; }): Promise<{ bead: Bead; agent: Agent }> { await this.ensureInitialized(); @@ -1689,6 +1711,11 @@ export class TownDO extends DurableObject { metadata: input.metadata, }); + // If a convoy_id was provided, add the bead to the convoy (tracks dep + metadata + counter) + if (input.convoyId) { + beadOps.addBeadToConvoy(this.sql, createdBead.bead_id, input.convoyId); + } + // Insert dependency rows before hooking/dispatching so the bead's // blocker set is complete before any agent can start work on it. // This is atomic within the DO's synchronous SQLite transaction. diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index f41e2d4052..95886d8a20 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -910,6 +910,153 @@ export function getConvoyFeatureBranch(sql: SqlStorage, convoyId: string): strin return z.object({ feature_branch: z.string().nullable() }).parse(rows[0]).feature_branch; } +// ── Convoy Membership ─────────────────────────────────────────────── + +/** + * Add a bead to an existing convoy. Creates the 'tracks' dependency, + * merges convoy_id + feature_branch into the bead's metadata, and + * increments the convoy's total_beads counter. + * + * No-ops if the bead already tracks this convoy. + */ +export function addBeadToConvoy(sql: SqlStorage, beadId: string, convoyId: string): void { + // Verify both exist + const bead = getBead(sql, beadId); + if (!bead) throw new Error(`Bead ${beadId} not found`); + + const convoyBead = getBead(sql, convoyId); + if (!convoyBead) throw new Error(`Convoy ${convoyId} not found`); + if (convoyBead.type !== 'convoy') { + throw new Error(`Bead ${convoyId} is not a convoy (type: ${convoyBead.type})`); + } + + // Check if already tracked + const existing = getConvoyForBead(sql, beadId); + if (existing === convoyId) return; // already a member + if (existing) { + throw new Error( + `Bead ${beadId} already belongs to convoy ${existing}. Remove it first before adding to a different convoy.` + ); + } + + // Insert 'tracks' dependency + query( + sql, + /* sql */ ` + INSERT INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, 'tracks') + ON CONFLICT DO NOTHING + `, + [beadId, convoyId] + ); + + // Merge convoy_id + feature_branch into bead metadata + const featureBranch = getConvoyFeatureBranch(sql, convoyId); + const timestamp = now(); + const metadataPatch: Record = { convoy_id: convoyId }; + if (featureBranch) metadataPatch.feature_branch = featureBranch; + + const existingMetadata: Record = + typeof bead.metadata === 'string' ? JSON.parse(bead.metadata) : (bead.metadata ?? {}); + const merged = { ...existingMetadata, ...metadataPatch }; + + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = ?, + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [JSON.stringify(merged), timestamp, beadId] + ); + + // Increment total_beads + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = ${convoy_metadata.columns.total_beads} + 1 + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); +} + +/** + * Remove a bead from its convoy. Deletes the 'tracks' dependency, + * strips convoy_id + feature_branch from metadata, and decrements + * the convoy's total_beads counter. + * + * No-ops if the bead is not in any convoy. + */ +export function removeBeadFromConvoy(sql: SqlStorage, beadId: string): string | null { + const convoyId = getConvoyForBead(sql, beadId); + if (!convoyId) return null; + + // Remove 'tracks' dependency + query( + sql, + /* sql */ ` + DELETE FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId, convoyId] + ); + + // Strip convoy_id + feature_branch from metadata + const bead = getBead(sql, beadId); + if (bead) { + const existingMetadata: Record = + typeof bead.metadata === 'string' ? JSON.parse(bead.metadata) : (bead.metadata ?? {}); + delete existingMetadata.convoy_id; + delete existingMetadata.feature_branch; + const timestamp = now(); + + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = ?, + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [JSON.stringify(existingMetadata), timestamp, beadId] + ); + } + + // Decrement total_beads (floor at 0) + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = MAX(${convoy_metadata.columns.total_beads} - 1, 0) + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + + // If the bead was already closed/failed, also decrement closed_beads + if (bead && (bead.status === 'closed' || bead.status === 'failed')) { + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.closed_beads} = MAX(${convoy_metadata.columns.closed_beads} - 1, 0) + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + } + + return convoyId; +} + // ── Bead Dependency Editing ───────────────────────────────────────── /** diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index cede40ab0f..156dccaeb4 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -25,6 +25,7 @@ const MayorSlingBody = z.object({ body: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), depends_on: z.array(z.string().min(1)).optional(), + convoy_id: z.string().min(1).optional(), }); const MayorSlingBatchBody = z @@ -157,6 +158,7 @@ export async function handleMayorSling(c: Context, params: { townId: body: parsed.data.body, metadata: parsed.data.metadata, dependsOn: parsed.data.depends_on, + convoyId: parsed.data.convoy_id, }); console.log( @@ -395,6 +397,7 @@ const BeadUpdateBody = z metadata: z.record(z.string(), z.unknown()).optional(), rig_id: z.string().min(1).nullable().optional(), parent_bead_id: z.string().min(1).nullable().optional(), + convoy_id: z.string().min(1).nullable().optional(), }) .refine( data => @@ -405,7 +408,8 @@ const BeadUpdateBody = z data.status !== undefined || data.metadata !== undefined || data.rig_id !== undefined || - data.parent_bead_id !== undefined, + data.parent_bead_id !== undefined || + data.convoy_id !== undefined, { message: 'At least one field must be provided' } ); @@ -460,7 +464,24 @@ export async function handleMayorBeadUpdate( return c.json(resError('Bead does not belong to this rig'), 403); } - const bead = await town.updateBead(params.beadId, parsed.data, 'mayor'); + // Handle convoy_id changes separately — convoy membership is managed + // via 'tracks' dependencies and counter updates, not plain field updates. + if (parsed.data.convoy_id !== undefined) { + // null → remove from current convoy; string → add to that convoy + if (parsed.data.convoy_id === null) { + await town.removeBeadFromConvoy(params.beadId); + } else { + await town.addBeadToConvoy(params.beadId, parsed.data.convoy_id); + } + } + + // Forward remaining fields (excluding convoy_id) to the normal update path + const { convoy_id: _convoyId, ...fieldUpdates } = parsed.data; + const hasFieldUpdates = Object.values(fieldUpdates).some(v => v !== undefined); + + const bead = hasFieldUpdates + ? await town.updateBead(params.beadId, fieldUpdates, 'mayor') + : await town.getBeadAsync(params.beadId); return c.json(resSuccess(bead)); } From 1486690ab858e8f10e5d3aaf6125bcf910a5b5ee Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Mar 2026 16:57:16 -0500 Subject: [PATCH 12/12] fix(gastown): address review comments on convoy membership helpers - Validate convoy_id before creating the bead in slingBead() to prevent orphan rows when the convoy doesn't exist or isn't type=convoy. - addBeadToConvoy now recounts closed_beads (via shared recountConvoyClosedBeads helper) so adding an already-closed bead is reflected correctly, and clears the ready_to_land flag when adding an open bead to a convoy that was already marked complete. - removeBeadFromConvoy uses recountConvoyClosedBeads instead of a naive decrement, matching updateConvoyProgress's MR-aware counting logic. --- cloudflare-gastown/src/dos/Town.do.ts | 13 +++- cloudflare-gastown/src/dos/town/beads.ts | 82 +++++++++++++++++++----- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index f68ee09433..211639da78 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1702,6 +1702,16 @@ export class TownDO extends DurableObject { }): Promise<{ bead: Bead; agent: Agent }> { await this.ensureInitialized(); + // Validate the convoy exists before creating the bead so a bad + // convoy_id doesn't leave behind an orphan bead row. + if (input.convoyId) { + const convoyBead = beadOps.getBead(this.sql, input.convoyId); + if (!convoyBead) throw new Error(`Convoy ${input.convoyId} not found`); + if (convoyBead.type !== 'convoy') { + throw new Error(`Bead ${input.convoyId} is not a convoy (type: ${convoyBead.type})`); + } + } + const createdBead = beadOps.createBead(this.sql, { type: 'issue', title: input.title, @@ -1711,7 +1721,8 @@ export class TownDO extends DurableObject { metadata: input.metadata, }); - // If a convoy_id was provided, add the bead to the convoy (tracks dep + metadata + counter) + // If a convoy_id was provided, add the bead to the convoy (tracks dep + metadata + counter). + // The convoy was already validated above, so addBeadToConvoy won't throw for a missing convoy. if (input.convoyId) { beadOps.addBeadToConvoy(this.sql, createdBead.bead_id, input.convoyId); } diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 95886d8a20..3a641db71d 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -910,6 +910,48 @@ export function getConvoyFeatureBranch(sql: SqlStorage, convoyId: string): strin return z.object({ feature_branch: z.string().nullable() }).parse(rows[0]).feature_branch; } +/** + * Recount closed_beads for a convoy using the same logic as + * updateConvoyProgress: a tracked bead counts as closed only when + * it is closed/failed AND has no pending merge_request child beads. + */ +function recountConvoyClosedBeads(sql: SqlStorage, convoyId: string): void { + const countRows = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(1) AS count FROM ${bead_dependencies} AS tracked + INNER JOIN ${beads} AS tracked_bead + ON tracked.${bead_dependencies.columns.bead_id} = tracked_bead.${beads.columns.bead_id} + WHERE tracked.${bead_dependencies.columns.depends_on_bead_id} = ? + AND tracked.${bead_dependencies.columns.dependency_type} = 'tracks' + AND tracked_bead.${beads.columns.status} IN ('closed', 'failed') + AND NOT EXISTS ( + SELECT 1 FROM ${bead_dependencies} AS mr_dep + INNER JOIN ${beads} AS mr_bead + ON mr_dep.${bead_dependencies.columns.bead_id} = mr_bead.${beads.columns.bead_id} + WHERE mr_dep.${bead_dependencies.columns.depends_on_bead_id} = tracked_bead.${beads.columns.bead_id} + AND mr_dep.${bead_dependencies.columns.dependency_type} = 'tracks' + AND mr_bead.${beads.columns.type} = 'merge_request' + AND mr_bead.${beads.columns.status} IN ('open', 'in_progress') + ) + `, + [convoyId] + ), + ]; + const closedCount = z.object({ count: z.number() }).parse(countRows[0]).count; + + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.closed_beads} = ? + WHERE ${convoy_metadata.bead_id} = ? + `, + [closedCount, convoyId] + ); +} + // ── Convoy Membership ─────────────────────────────────────────────── /** @@ -974,7 +1016,9 @@ export function addBeadToConvoy(sql: SqlStorage, beadId: string, convoyId: strin [JSON.stringify(merged), timestamp, beadId] ); - // Increment total_beads + // Increment total_beads and recount closed_beads (the bead may already + // be closed/failed, so a naive +1 on total_beads alone would leave + // closed_beads stale). query( sql, /* sql */ ` @@ -984,6 +1028,23 @@ export function addBeadToConvoy(sql: SqlStorage, beadId: string, convoyId: strin `, [convoyId] ); + recountConvoyClosedBeads(sql, convoyId); + + // If the bead is still open, clear the ready_to_land flag on the convoy + // in case it was already set — a new open bead means the convoy is not + // complete and must not submit the final landing MR. + if (bead.status !== 'closed' && bead.status !== 'failed') { + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_remove(COALESCE(${beads.metadata}, '{}'), '$.ready_to_land'), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [timestamp, convoyId] + ); + } } /** @@ -1030,7 +1091,10 @@ export function removeBeadFromConvoy(sql: SqlStorage, beadId: string): string | ); } - // Decrement total_beads (floor at 0) + // Decrement total_beads and recount closed_beads. A naive decrement of + // closed_beads is unreliable because updateConvoyProgress excludes beads + // with pending MR children from the count — a bead that is closed but + // mid-review was never counted, so decrementing would undercount. query( sql, /* sql */ ` @@ -1040,19 +1104,7 @@ export function removeBeadFromConvoy(sql: SqlStorage, beadId: string): string | `, [convoyId] ); - - // If the bead was already closed/failed, also decrement closed_beads - if (bead && (bead.status === 'closed' || bead.status === 'failed')) { - query( - sql, - /* sql */ ` - UPDATE ${convoy_metadata} - SET ${convoy_metadata.columns.closed_beads} = MAX(${convoy_metadata.columns.closed_beads} - 1, 0) - WHERE ${convoy_metadata.bead_id} = ? - `, - [convoyId] - ); - } + recountConvoyClosedBeads(sql, convoyId); return convoyId; }