diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index cb728fde8f..3f4ed8d1ea 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -300,6 +300,8 @@ export class MayorGastownClient { title: string; body?: string; metadata?: Record; + depends_on?: string[]; + convoy_id?: string; }): Promise { return this.request(this.mayorPath('/sling'), { method: 'POST', @@ -381,6 +383,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')); } @@ -398,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 1cec0a37cf..3b73f8b4b9 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -67,22 +67,46 @@ 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(), + 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; + // 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, + convoy_id: args.convoy_id, }); - return [ + + 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)`); + } + 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'); }, }), @@ -304,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'), @@ -322,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, { @@ -330,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}".`; }, @@ -475,5 +509,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.`; + }, + }), }; } 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.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index d73cfdded9..211639da78 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -833,6 +833,72 @@ 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 }, + }); + // 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()); + } + } + 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. @@ -1631,9 +1697,21 @@ export class TownDO extends DurableObject { body?: string; priority?: string; metadata?: Record; + dependsOn?: string[]; + convoyId?: string; }): 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, @@ -1643,6 +1721,21 @@ export class TownDO extends DurableObject { metadata: input.metadata, }); + // 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); + } + + // 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); @@ -1650,11 +1743,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. - 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 }; } diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index cf0f009346..3a641db71d 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -13,6 +13,7 @@ import { } from '../../db/tables/bead-events.table'; import { bead_dependencies, + BeadDependencyRecord, createTableBeadDependencies, getIndexesBeadDependencies, } from '../../db/tables/bead-dependencies.table'; @@ -908,3 +909,337 @@ 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; } + +/** + * 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 ─────────────────────────────────────────────── + +/** + * 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 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 */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = ${convoy_metadata.columns.total_beads} + 1 + WHERE ${convoy_metadata.bead_id} = ? + `, + [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] + ); + } +} + +/** + * 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 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 */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.total_beads} = MAX(${convoy_metadata.columns.total_beads} - 1, 0) + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ); + recountConvoyClosedBeads(sql, convoyId); + + return convoyId; +} + +// ── 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: 'blocks' | 'tracks' | 'parent-child' +): 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 === undefined) break; + 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; +} diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 8282446cdf..4e36f52fcf 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,21 @@ 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..f53d0bb9f2 --- /dev/null +++ b/cloudflare-gastown/src/handlers/bead-dependencies.handler.ts @@ -0,0 +1,76 @@ +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 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: EditableDependencyType.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; rigId: 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); + 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, + 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; 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 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..156dccaeb4 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -24,6 +24,8 @@ 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(), + convoy_id: z.string().min(1).optional(), }); const MayorSlingBatchBody = z @@ -152,7 +154,11 @@ 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, + convoyId: parsed.data.convoy_id, }); console.log( @@ -391,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 => @@ -401,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' } ); @@ -456,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)); } 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.