diff --git a/cloudflare-gastown/container/plugin/client.test.ts b/cloudflare-gastown/container/plugin/client.test.ts index c95d13413b..35f05bb6b5 100644 --- a/cloudflare-gastown/container/plugin/client.test.ts +++ b/cloudflare-gastown/container/plugin/client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { GastownClient, GastownApiError, createClientFromEnv } from './client'; -import type { GastownEnv } from './types'; +import { GastownClient, MayorGastownClient, GastownApiError, createClientFromEnv } from './client'; +import type { GastownEnv, MayorGastownEnv } from './types'; const TEST_ENV: GastownEnv = { apiUrl: 'https://gastown.example.com', @@ -285,3 +285,76 @@ describe('createClientFromEnv', () => { expect(() => createClientFromEnv()).toThrow('GASTOWN_API_URL, GASTOWN_AGENT_ID'); }); }); + +// ── MayorGastownClient tests ───────────────────────────────────────────── + +const MAYOR_ENV: MayorGastownEnv = { + apiUrl: 'https://gastown.example.com', + sessionToken: 'mayor-jwt-token', + agentId: 'mayor-agent-1', + townId: 'town-1', +}; + +describe('MayorGastownClient', () => { + let client: MayorGastownClient; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + client = new MayorGastownClient(MAYOR_ENV); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('slingBatch() posts to sling-batch endpoint', async () => { + const responseData = { + convoy: { id: 'convoy-1', title: 'Test Convoy', status: 'active', total_beads: 2 }, + beads: [], + }; + const fetchMock = mockFetch(responseData); + globalThis.fetch = fetchMock; + + const result = await client.slingBatch({ + rig_id: 'rig-1', + convoy_title: 'Test Convoy', + tasks: [{ title: 'Task 1' }, { title: 'Task 2', body: 'Details' }], + }); + + expect(result).toEqual(responseData); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://gastown.example.com/api/mayor/town-1/tools/sling-batch'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + rig_id: 'rig-1', + convoy_title: 'Test Convoy', + tasks: [{ title: 'Task 1' }, { title: 'Task 2', body: 'Details' }], + }); + }); + + it('listConvoys() fetches convoy list', async () => { + const convoys = [{ id: 'convoy-1', title: 'Test', status: 'active' }]; + globalThis.fetch = mockFetch(convoys); + + const result = await client.listConvoys(); + expect(result).toEqual(convoys); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0] as [string]; + expect(url).toBe('https://gastown.example.com/api/mayor/town-1/tools/convoys'); + }); + + it('getConvoyStatus() fetches detailed convoy', async () => { + const detail = { + id: 'convoy-1', + title: 'Test', + beads: [{ bead_id: 'b1', title: 'T1', status: 'open', assignee_agent_name: null }], + }; + globalThis.fetch = mockFetch(detail); + + const result = await client.getConvoyStatus('convoy-1'); + expect(result).toEqual(detail); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0] as [string]; + expect(url).toBe('https://gastown.example.com/api/mayor/town-1/tools/convoys/convoy-1'); + }); +}); diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 7af95cf8ae..8f9047a370 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -5,11 +5,14 @@ import type { BeadPriority, BeadStatus, BeadType, + Convoy, + ConvoyDetail, GastownEnv, Mail, MayorGastownEnv, PrimeContext, Rig, + SlingBatchResult, SlingResult, } from './types'; @@ -281,6 +284,26 @@ export class MayorGastownClient { }), }); } + + async slingBatch(input: { + rig_id: string; + convoy_title: string; + tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; + merge_mode?: 'review-then-land' | 'review-and-merge'; + }): Promise { + return this.request(this.mayorPath('/sling-batch'), { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async listConvoys(): Promise { + return this.request(this.mayorPath('/convoys')); + } + + async getConvoyStatus(convoyId: string): Promise { + return this.request(this.mayorPath(`/convoys/${convoyId}`)); + } } export class GastownApiError extends Error { diff --git a/cloudflare-gastown/container/plugin/mayor-tools.test.ts b/cloudflare-gastown/container/plugin/mayor-tools.test.ts new file mode 100644 index 0000000000..13c3a245ce --- /dev/null +++ b/cloudflare-gastown/container/plugin/mayor-tools.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { MayorGastownClient } from './client'; +import type { + Agent, + Bead, + Convoy, + ConvoyDetail, + Rig, + SlingBatchResult, + SlingResult, +} from './types'; + +// Mock the @kilocode/plugin module to avoid its broken ESM import chain. +import { z } from 'zod'; + +function toolFn(def: Record) { + return def; +} +toolFn.schema = z; + +vi.mock('@kilocode/plugin', () => ({ + tool: toolFn, +})); + +const { createMayorTools } = await import('./mayor-tools'); + +const FAKE_BEAD: Bead = { + bead_id: 'bead-1', + type: 'issue', + status: 'in_progress', + title: 'Add auth middleware', + body: null, + rig_id: 'rig-1', + parent_bead_id: null, + assignee_agent_bead_id: 'agent-bead-1', + priority: 'medium', + labels: [], + metadata: {}, + created_by: null, + created_at: '2026-03-05T00:00:00Z', + updated_at: '2026-03-05T00:00:00Z', + closed_at: null, +}; + +const FAKE_AGENT: Agent = { + id: 'agent-1', + rig_id: 'rig-1', + role: 'polecat', + name: 'Toast', + identity: 'toast-rig1', + status: 'working', + current_hook_bead_id: 'bead-1', + dispatch_attempts: 0, + last_activity_at: '2026-03-05T00:00:00Z', + checkpoint: null, + created_at: '2026-03-05T00:00:00Z', +}; + +const FAKE_CONVOY: Convoy = { + id: 'convoy-1', + title: 'JWT Authentication', + status: 'active', + total_beads: 3, + closed_beads: 1, + created_by: null, + created_at: '2026-03-05T00:00:00Z', + landed_at: null, +}; + +function makeFakeMayorClient(overrides: Partial = {}): MayorGastownClient { + return { + sling: vi.fn<() => Promise>().mockResolvedValue({ + bead: FAKE_BEAD, + agent: FAKE_AGENT, + }), + listRigs: vi.fn<() => Promise>().mockResolvedValue([]), + listBeads: vi.fn<() => Promise>().mockResolvedValue([]), + listAgents: vi.fn<() => Promise>().mockResolvedValue([]), + sendMail: vi.fn().mockResolvedValue(undefined), + slingBatch: vi.fn<() => Promise>().mockResolvedValue({ + convoy: FAKE_CONVOY, + beads: [ + { + bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' }, + agent: { ...FAKE_AGENT, id: 'agent-1', name: 'Toast' }, + }, + { + bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' }, + agent: { ...FAKE_AGENT, id: 'agent-2', name: 'Muffin' }, + }, + { + bead: { ...FAKE_BEAD, bead_id: 'bead-3', title: 'Task 3' }, + agent: { ...FAKE_AGENT, id: 'agent-3', name: 'Bagel' }, + }, + ], + }), + listConvoys: vi.fn<() => Promise>().mockResolvedValue([FAKE_CONVOY]), + getConvoyStatus: vi.fn<() => Promise>().mockResolvedValue({ + ...FAKE_CONVOY, + beads: [ + { + bead_id: 'bead-1', + title: 'Task 1', + status: 'closed', + rig_id: 'rig-1', + assignee_agent_name: 'Toast', + }, + { + bead_id: 'bead-2', + title: 'Task 2', + status: 'in_progress', + rig_id: 'rig-1', + assignee_agent_name: 'Muffin', + }, + { + bead_id: 'bead-3', + title: 'Task 3', + status: 'open', + rig_id: 'rig-1', + assignee_agent_name: 'Bagel', + }, + ], + }), + ...overrides, + } as unknown as MayorGastownClient; +} + +const CTX = undefined as never; + +describe('mayor tools', () => { + let client: ReturnType; + let tools: ReturnType; + + beforeEach(() => { + client = makeFakeMayorClient(); + tools = createMayorTools(client); + }); + + describe('gt_sling', () => { + it('delegates a single task and returns result summary', async () => { + const result = await tools.gt_sling.execute( + { rig_id: 'rig-1', title: 'Fix bug', body: 'Details here' }, + CTX + ); + expect(result).toContain('Task slung successfully'); + expect(result).toContain('bead-1'); + expect(client.sling).toHaveBeenCalledWith({ + rig_id: 'rig-1', + title: 'Fix bug', + body: 'Details here', + metadata: undefined, + }); + }); + }); + + describe('gt_sling_batch', () => { + it('creates a convoy with multiple beads', async () => { + const tasks = [ + { title: 'Task 1', body: 'Details 1' }, + { title: 'Task 2' }, + { title: 'Task 3', body: 'Details 3' }, + ]; + + const result = await tools.gt_sling_batch.execute( + { rig_id: 'rig-1', convoy_title: 'JWT Authentication', tasks }, + CTX + ); + + expect(result).toContain('Convoy created: "JWT Authentication"'); + expect(result).toContain('Tracking 3 beads'); + expect(result).toContain('Task 1'); + expect(result).toContain('Task 2'); + expect(result).toContain('Task 3'); + expect(client.slingBatch).toHaveBeenCalledWith({ + rig_id: 'rig-1', + convoy_title: 'JWT Authentication', + tasks: [ + { title: 'Task 1', body: 'Details 1' }, + { title: 'Task 2' }, + { title: 'Task 3', body: 'Details 3' }, + ], + }); + }); + + it('passes depends_on through to the client', async () => { + const tasks = [ + { title: 'Scaffold' }, + { title: 'Add API', depends_on: [0] }, + { title: 'Add tests', depends_on: [0, 1] }, + ]; + + await tools.gt_sling_batch.execute( + { rig_id: 'rig-1', convoy_title: 'With Dependencies', tasks }, + CTX + ); + + expect(client.slingBatch).toHaveBeenCalledWith({ + rig_id: 'rig-1', + convoy_title: 'With Dependencies', + tasks: [ + { title: 'Scaffold' }, + { title: 'Add API', depends_on: [0] }, + { title: 'Add tests', depends_on: [0, 1] }, + ], + }); + }); + }); + + describe('gt_list_convoys', () => { + it('returns convoys as JSON', async () => { + const result = await tools.gt_list_convoys.execute({}, CTX); + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe('convoy-1'); + expect(parsed[0].title).toBe('JWT Authentication'); + expect(client.listConvoys).toHaveBeenCalledOnce(); + }); + + it('returns a message when no active convoys', async () => { + client = makeFakeMayorClient({ + listConvoys: vi.fn<() => Promise>().mockResolvedValue([]), + }); + tools = createMayorTools(client); + + const result = await tools.gt_list_convoys.execute({}, CTX); + expect(result).toContain('No active convoys'); + }); + }); + + describe('gt_convoy_status', () => { + it('returns detailed convoy status as JSON', async () => { + const result = await tools.gt_convoy_status.execute({ convoy_id: 'convoy-1' }, CTX); + const parsed = JSON.parse(result); + expect(parsed.id).toBe('convoy-1'); + expect(parsed.beads).toHaveLength(3); + expect(parsed.beads[0].status).toBe('closed'); + expect(parsed.beads[1].assignee_agent_name).toBe('Muffin'); + expect(client.getConvoyStatus).toHaveBeenCalledWith('convoy-1'); + }); + }); + + describe('gt_list_rigs', () => { + it('returns empty message when no rigs', async () => { + const result = await tools.gt_list_rigs.execute({}, CTX); + expect(result).toContain('No rigs configured'); + }); + }); +}); diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index d29091ebaf..5302c65fb9 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -120,6 +120,99 @@ export function createMayorTools(client: MayorGastownClient) { }, }), + gt_sling_batch: tool({ + description: + 'Sling multiple beads as a tracked convoy. Use this when a task should be broken ' + + 'into parallel sub-tasks that you want to track as a group. Creates N beads + 1 convoy, ' + + 'assigns polecats, and dispatches all in one call. Use gt_list_convoys to check progress later.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig to assign work to'), + convoy_title: tool.schema + .string() + .describe('Title for the convoy — describes the overall task being decomposed'), + tasks: tool.schema + .array( + tool.schema.object({ + title: tool.schema.string().describe('Short title describing the sub-task'), + body: tool.schema + .string() + .describe('Detailed requirements for the sub-task') + .optional(), + depends_on: tool.schema + .array(tool.schema.number().int().min(0)) + .describe( + 'Zero-based indices of tasks in this array that must complete before this task can start. ' + + 'Example: [0] means this task depends on the first task. Omit or use [] for tasks with no dependencies.' + ) + .optional(), + }) + ) + .min(1) + .describe('Array of sub-tasks to create as beads in the convoy'), + merge_mode: tool.schema + .enum(['review-then-land', 'review-and-merge']) + .describe( + 'Controls how completed beads are handled:\n' + + '- "review-then-land" (default): Each bead is reviewed by the refinery and merged into the convoy feature branch. ' + + 'Only at the end of the convoy does a PR or merge into main occur. Best for tightly coupled work where ' + + 'intermediate PRs would be noisy or where tasks build on each other.\n' + + '- "review-and-merge": Each bead goes through the full review + merge/PR cycle independently. ' + + 'Best for loosely coupled tasks where each bead stands on its own and you want incremental merges.' + ) + .optional(), + }, + async execute(args) { + const result = await client.slingBatch({ + rig_id: args.rig_id, + convoy_title: args.convoy_title, + tasks: args.tasks, + merge_mode: args.merge_mode, + }); + + const beadLines = result.beads.map( + (b: { bead: { title: string }; agent: { name: string; id: string } }, i: number) => + ` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})` + ); + const mode = args.merge_mode ?? 'review-then-land'; + return [ + `Convoy created: "${result.convoy.title}" (${result.convoy.id})`, + `Merge mode: ${mode}`, + `Tracking ${result.convoy.total_beads} beads:`, + ...beadLines, + mode === 'review-then-land' + ? `Beads will be reviewed and merged into the convoy feature branch. A final PR/merge to main occurs when all beads are done.` + : `Each bead will go through the full review + merge/PR cycle independently.`, + ].join('\n'); + }, + }), + + gt_list_convoys: tool({ + description: + 'List active convoys with progress. Shows how many beads are closed vs total for each convoy. ' + + 'Use this to check on batched work or answer "how is X going?" questions.', + args: {}, + async execute() { + const convoys = await client.listConvoys(); + if (convoys.length === 0) { + return 'No active convoys. All batched work has either landed or none has been created.'; + } + return JSON.stringify(convoys, null, 2); + }, + }), + + gt_convoy_status: tool({ + description: + 'Show detailed status of a convoy: each tracked bead with its status and assignee. ' + + 'Use this for a detailed progress report on a specific batch of work.', + args: { + convoy_id: tool.schema.string().describe('The UUID of the convoy to inspect'), + }, + async execute(args) { + const status = await client.getConvoyStatus(args.convoy_id); + return JSON.stringify(status, null, 2); + }, + }), + gt_mail_send: tool({ description: 'Send a mail message to an agent in any rig. ' + diff --git a/cloudflare-gastown/container/plugin/types.ts b/cloudflare-gastown/container/plugin/types.ts index 5dee34b039..7a78c08aa0 100644 --- a/cloudflare-gastown/container/plugin/types.ts +++ b/cloudflare-gastown/container/plugin/types.ts @@ -87,6 +87,35 @@ export type SlingResult = { agent: Agent; }; +// Sling batch result (convoy + beads + agents) +export type SlingBatchResult = { + convoy: Convoy; + beads: Array<{ bead: Bead; agent: Agent }>; +}; + +// Convoy summary (returned by list and status endpoints) +export type Convoy = { + id: string; + title: string; + status: 'active' | 'landed'; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; +}; + +// Detailed convoy status with per-bead breakdown +export type ConvoyDetail = Convoy & { + beads: Array<{ + bead_id: string; + title: string; + status: BeadStatus; + rig_id: string | null; + assignee_agent_name: string | null; + }>; +}; + // Environment variable config for the plugin (rig-scoped agents) export type GastownEnv = { apiUrl: string; diff --git a/cloudflare-gastown/container/src/git-manager.ts b/cloudflare-gastown/container/src/git-manager.ts index a5e4d89fd0..57aec3c401 100644 --- a/cloudflare-gastown/container/src/git-manager.ts +++ b/cloudflare-gastown/container/src/git-manager.ts @@ -4,6 +4,40 @@ import type { CloneOptions, WorktreeOptions } from './types'; const WORKSPACE_ROOT = '/workspace/rigs'; +// ── Per-rig mutex ──────────────────────────────────────────────────────── +// Git operations (clone, fetch, worktree add/remove) on the same bare repo +// must be serialized because git acquires index.lock internally. Concurrent +// operations on different rigs are unaffected. + +const rigLocks = new Map>(); + +function withRigLock(rigId: string, fn: () => Promise): Promise { + const prev = rigLocks.get(rigId) ?? Promise.resolve(); + const next = prev.then(fn, fn); + // Keep the chain alive for the next caller; clean up when idle. + rigLocks.set( + rigId, + next.then( + () => {}, + () => {} + ) + ); + void next.finally(() => { + // Remove the entry once the chain is idle (no pending waiters). + // If another caller chained onto `next` between our set and this + // finally, the map value will have changed — only delete if it + // still points to our void-mapped promise. + const current = rigLocks.get(rigId); + if (current) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + current.then(() => { + if (rigLocks.get(rigId) === current) rigLocks.delete(rigId); + }); + } + }); + return next; +} + /** * Reject path segments that could escape the workspace via traversal. * Allows alphanumeric, hyphens, underscores, dots, and forward slashes @@ -151,7 +185,13 @@ async function worktreeDir(rigId: string, branch: string): Promise { * If the repo is already cloned, fetches latest instead. * When envVars contains GIT_TOKEN/GITLAB_TOKEN, constructs authenticated URLs. */ -export async function cloneRepo( +export function cloneRepo( + options: CloneOptions & { envVars?: Record } +): Promise { + return withRigLock(options.rigId, () => cloneRepoInner(options)); +} + +async function cloneRepoInner( options: CloneOptions & { envVars?: Record } ): Promise { validateGitUrl(options.gitUrl); @@ -189,7 +229,11 @@ export async function cloneRepo( * Create an isolated git worktree for an agent's branch. * If the worktree already exists, resets it to track the branch. */ -export async function createWorktree(options: WorktreeOptions): Promise { +export function createWorktree(options: WorktreeOptions): Promise { + return withRigLock(options.rigId, () => createWorktreeInner(options)); +} + +async function createWorktreeInner(options: WorktreeOptions): Promise { const repo = await repoDir(options.rigId); const dir = await worktreeDir(options.rigId, options.branch); @@ -216,14 +260,16 @@ export async function createWorktree(options: WorktreeOptions): Promise /** * Remove a git worktree. */ -export async function removeWorktree(rigId: string, branch: string): Promise { - const repo = await repoDir(rigId); - const dir = await worktreeDir(rigId, branch); +export function removeWorktree(rigId: string, branch: string): Promise { + return withRigLock(rigId, async () => { + const repo = await repoDir(rigId); + const dir = await worktreeDir(rigId, branch); - if (!(await pathExists(dir))) return; + if (!(await pathExists(dir))) return; - await exec('git', ['worktree', 'remove', '--force', dir], repo); - console.log(`Removed worktree at ${dir}`); + await exec('git', ['worktree', 'remove', '--force', dir], repo); + console.log(`Removed worktree at ${dir}`); + }); } /** diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index 1a1db0aa9e..ea4afcfda9 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -34,6 +34,12 @@ const eventSinks = new Set<(agentId: string, event: string, data: unknown) => vo let nextPort = 4096; const startTime = Date.now(); +// Mutex for ensureSDKServer — createKilo() reads process.cwd() and +// process.env during startup, so concurrent calls with different workdirs +// would corrupt each other's globals. This serializes server creation only; +// once created, the SDK instance is reused without locking. +let sdkServerLock: Promise = Promise.resolve(); + export function getUptime(): number { return Date.now() - startTime; } @@ -115,11 +121,17 @@ function broadcastEvent(agentId: string, event: string, data: unknown): void { /** * Get or create an SDK server instance for a workdir. + * + * createKilo() reads process.cwd() and process.env during startup, so + * we must serialize server creation to prevent concurrent calls from + * corrupting each other's globals. Once created, the SDK instance is + * cached and returned without locking. */ async function ensureSDKServer( workdir: string, env: Record ): Promise<{ client: KiloClient; port: number }> { + // Fast path: reuse existing instance without locking. const existing = sdkInstances.get(workdir); if (existing) { return { @@ -128,43 +140,64 @@ async function ensureSDKServer( }; } - const port = nextPort++; - console.log(`${MANAGER_LOG} Starting SDK server on port ${port} for ${workdir}`); + // Slow path: serialize server creation. createKilo() reads process.cwd() + // and process.env, so concurrent calls with different workdirs must not + // overlap. We capture the previous lock and install our own as the new + // tail in the same synchronous microtask — no await between read and + // write — so no concurrent caller can observe a stale sdkServerLock. + const previousLock = sdkServerLock; + let releaseLock!: () => void; + sdkServerLock = new Promise(resolve => { + releaseLock = resolve; + }); - // Save env vars that we'll mutate, set them for createKilo, then restore. - // This avoids permanent global mutation when multiple agents start with - // different env — each server gets the env it was started with. - const envSnapshot: Record = {}; - for (const key of Object.keys(env)) { - envSnapshot[key] = process.env[key]; - process.env[key] = env[key]; - } + await previousLock; - // Save and set CWD for the server - const prevCwd = process.cwd(); try { - process.chdir(workdir); - const { client, server } = await createKilo({ - hostname: '127.0.0.1', - port, - timeout: 30_000, - }); + // Re-check after acquiring lock — another caller may have created it. + const cached = sdkInstances.get(workdir); + if (cached) { + return { + client: cached.client, + port: parseInt(new URL(cached.server.url).port), + }; + } - const instance: SDKInstance = { client, server, sessionCount: 0 }; - sdkInstances.set(workdir, instance); + const port = nextPort++; + console.log(`${MANAGER_LOG} Starting SDK server on port ${port} for ${workdir}`); - console.log(`${MANAGER_LOG} SDK server started: ${server.url}`); - return { client, port }; - } finally { - process.chdir(prevCwd); - // Restore previous env values - for (const [key, prev] of Object.entries(envSnapshot)) { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; + const envSnapshot: Record = {}; + for (const key of Object.keys(env)) { + envSnapshot[key] = process.env[key]; + process.env[key] = env[key]; + } + + const prevCwd = process.cwd(); + try { + process.chdir(workdir); + const { client, server } = await createKilo({ + hostname: '127.0.0.1', + port, + timeout: 30_000, + }); + + const instance: SDKInstance = { client, server, sessionCount: 0 }; + sdkInstances.set(workdir, instance); + + console.log(`${MANAGER_LOG} SDK server started: ${server.url}`); + return { client, port }; + } finally { + process.chdir(prevCwd); + for (const [key, prev] of Object.entries(envSnapshot)) { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } } } + } finally { + releaseLock(); } } diff --git a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts b/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts index 4c0deb2807..1c267c99cc 100644 --- a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts +++ b/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts @@ -1,11 +1,24 @@ import { z } from 'zod'; import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; +export const ConvoyMergeMode = z.enum(['review-then-land', 'review-and-merge']); +export type ConvoyMergeMode = z.output; + export const ConvoyMetadataRecord = z.object({ bead_id: z.string(), total_beads: z.number(), closed_beads: z.number(), landed_at: z.string().nullable(), + /** The long-lived feature branch for this convoy. Sub-beads branch from and merge into this. */ + feature_branch: z.string().nullable(), + /** + * Controls how the refinery handles bead completions within a convoy: + * - 'review-then-land': Refinery reviews each bead on the feature branch; only at the + * end of the convoy does a PR or merge into main occur. (Default) + * - 'review-and-merge': Refinery reviews AND merges/creates PR for each bead + * individually, like standalone beads. + */ + merge_mode: ConvoyMergeMode.nullable(), }); export type ConvoyMetadataRecord = z.output; @@ -18,5 +31,15 @@ export function createTableConvoyMetadata(): string { total_beads: `integer not null default 0`, closed_beads: `integer not null default 0`, landed_at: `text`, + feature_branch: `text`, + merge_mode: `text check(merge_mode in ('review-then-land', 'review-and-merge'))`, }); } + +/** Idempotent ALTER statements for existing databases. */ +export function migrateConvoyMetadata(): string[] { + return [ + `ALTER TABLE convoy_metadata ADD COLUMN feature_branch text`, + `ALTER TABLE convoy_metadata ADD COLUMN merge_mode text check(merge_mode in ('review-then-land', 'review-and-merge'))`, + ]; +} diff --git a/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts b/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts deleted file mode 100644 index 7044eb2999..0000000000 --- a/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ConvoyBeadStatus = z.enum(['open', 'closed']); - -export const TownConvoyBeadRecord = z.object({ - convoy_id: z.string(), - bead_id: z.string(), - rig_id: z.string(), - status: ConvoyBeadStatus, -}); - -export type TownConvoyBeadRecord = z.output; - -export const town_convoy_beads = getTableFromZodSchema('town_convoy_beads', TownConvoyBeadRecord); - -export function createTableTownConvoyBeads(): string { - return getCreateTableQueryFromTable(town_convoy_beads, { - convoy_id: `text not null`, - bead_id: `text not null`, - rig_id: `text not null`, - status: `text not null check(status in ('open', 'closed')) default 'open'`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/town-convoys.table.ts b/cloudflare-gastown/src/db/tables/town-convoys.table.ts deleted file mode 100644 index 6594c27123..0000000000 --- a/cloudflare-gastown/src/db/tables/town-convoys.table.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ConvoyStatus = z.enum(['active', 'landed']); - -export const TownConvoyRecord = z.object({ - id: z.string(), - title: z.string(), - status: ConvoyStatus, - total_beads: z.number(), - closed_beads: z.number(), - created_by: z.string().nullable(), - created_at: z.string(), - landed_at: z.string().nullable(), -}); - -export type TownConvoyRecord = z.output; - -export const town_convoys = getTableFromZodSchema('town_convoys', TownConvoyRecord); - -export function createTableTownConvoys(): string { - return getCreateTableQueryFromTable(town_convoys, { - id: `text primary key`, - title: `text not null`, - status: `text not null check(status in ('active', 'landed')) default 'active'`, - total_beads: `integer not null default 0`, - closed_beads: `integer not null default 0`, - created_by: `text`, - created_at: `text not null`, - landed_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index e2c356abb2..410efb8722 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -35,9 +35,10 @@ import { ConvoyBeadRecord, } from '../db/tables/beads.table'; import { agent_metadata, AgentMetadataRecord } from '../db/tables/agent-metadata.table'; +import { review_metadata } from '../db/tables/review-metadata.table'; import { escalation_metadata } from '../db/tables/escalation-metadata.table'; import { convoy_metadata } from '../db/tables/convoy-metadata.table'; -import { bead_dependencies, BeadDependencyRecord } from '../db/tables/bead-dependencies.table'; +import { bead_dependencies } from '../db/tables/bead-dependencies.table'; import { query } from '../util/query.util'; import { getAgentDOStub } from './Agent.do'; import { getTownContainerStub } from './TownContainer.do'; @@ -138,6 +139,8 @@ type ConvoyEntry = { created_by: string | null; created_at: string; landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; }; function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { @@ -150,13 +153,16 @@ function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { created_by: row.created_by, created_at: row.created_at, landed_at: row.landed_at, + feature_branch: row.feature_branch, + merge_mode: row.merge_mode, }; } const CONVOY_JOIN = /* sql */ ` SELECT ${beads}.*, ${convoy_metadata.total_beads}, ${convoy_metadata.closed_beads}, - ${convoy_metadata.landed_at} + ${convoy_metadata.landed_at}, ${convoy_metadata.feature_branch}, + ${convoy_metadata.merge_mode} FROM ${beads} INNER JOIN ${convoy_metadata} ON ${beads.bead_id} = ${convoy_metadata.bead_id} `; @@ -207,6 +213,13 @@ export class TownDO extends DurableObject { // Rig registry rigs.initRigTables(this.sql); + + // Ensure the alarm loop is running. After a deploy/restart, the + // Cloudflare runtime normally delivers missed alarms, but if the alarm + // was never set or was deleted by destroy(), the loop is dead. Re-arm + // unconditionally so pending work (idle agents with hooks, open MR beads, + // stale reviews) gets processed. + await this.armAlarmIfNeeded(); } private _townId: string | null = null; @@ -345,28 +358,13 @@ export class TownDO extends DurableObject { async updateBeadStatus(beadId: string, status: string, agentId: string): Promise { await this.ensureInitialized(); + // Convoy progress is updated automatically inside beadOps.updateBeadStatus + // when the bead reaches a terminal status (closed/failed). const bead = beadOps.updateBeadStatus(this.sql, beadId, status, agentId); - // If closed and part of a convoy (via bead_dependencies), notify - if (status === 'closed') { - const convoyRows = [ - ...query( - this.sql, - /* sql */ ` - SELECT ${bead_dependencies.depends_on_bead_id} - FROM ${bead_dependencies} - WHERE ${bead_dependencies.bead_id} = ? - AND ${bead_dependencies.dependency_type} = 'tracks' - `, - [beadId] - ), - ]; - const parsed = BeadDependencyRecord.pick({ depends_on_bead_id: true }) - .array() - .parse(convoyRows); - for (const { depends_on_bead_id } of parsed) { - this.onBeadClosed({ convoyId: depends_on_bead_id, beadId }).catch(() => {}); - } + // When a bead closes, check if any blocked beads are now unblocked and dispatch them. + if (status === 'closed' || status === 'failed') { + this.dispatchUnblockedBeads(beadId); } return bead; @@ -967,6 +965,394 @@ export class TownDO extends DurableObject { return convoy; } + /** + * Force-close a convoy and all its tracked beads. Unhooks any agents + * still assigned to those beads so they return to the idle pool. + */ + async closeConvoy(convoyId: string): Promise { + await this.ensureInitialized(); + + const convoy = this.getConvoy(convoyId); + if (!convoy) return null; + + const timestamp = now(); + + // Find all tracked beads + const trackedRows = [ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.bead_id}, ${beads.status}, ${beads.assignee_agent_bead_id} + FROM ${bead_dependencies} + INNER JOIN ${beads} ON ${bead_dependencies.bead_id} = ${beads.bead_id} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [convoyId] + ), + ]; + + const TrackedRow = z.object({ + bead_id: z.string(), + status: z.string(), + assignee_agent_bead_id: z.string().nullable(), + }); + + for (const raw of trackedRows) { + const row = TrackedRow.parse(raw); + if (row.status === 'closed' || row.status === 'failed') continue; + + // Unhook agent if still assigned + if (row.assignee_agent_bead_id) { + try { + agents.unhookBead(this.sql, row.assignee_agent_bead_id); + } catch (err) { + console.warn( + `${TOWN_LOG} closeConvoy: unhookBead failed for agent=${row.assignee_agent_bead_id}`, + err + ); + } + } + + beadOps.updateBeadStatus(this.sql, row.bead_id, 'closed', 'system'); + } + + // Close the convoy bead itself if not already auto-landed by + // updateConvoyProgress (which fires when the last tracked bead closes). + const current = this.getConvoy(convoyId); + if (current && current.status !== 'landed') { + query( + this.sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.status} = 'closed', + ${beads.columns.closed_at} = ?, + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [timestamp, timestamp, convoyId] + ); + query( + this.sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.closed_beads} = ${convoy_metadata.columns.total_beads}, + ${convoy_metadata.columns.landed_at} = ? + WHERE ${convoy_metadata.bead_id} = ? + `, + [timestamp, convoyId] + ); + } + + console.log(`${TOWN_LOG} closeConvoy: force-closed convoy=${convoyId}`); + return this.getConvoy(convoyId); + } + + /** + * Atomic batch sling: create N beads + 1 convoy, assign polecats, dispatch. + * Used by the Mayor's gt_sling_batch tool. + */ + async slingConvoy(input: { + rigId: string; + convoyTitle: string; + tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; + merge_mode?: 'review-then-land' | 'review-and-merge'; + }): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent }> }> { + await this.ensureInitialized(); + + const convoyId = generateId(); + const timestamp = now(); + + // Generate a feature branch name for this convoy. + // Convention: convoy///head + // The /head suffix is required because git refs are file-based: a branch + // at path X prevents branches under X/. Agent branches live under + // /gt//, so the feature branch itself must + // end with a path component (/head) to act as a directory prefix. + const convoySlug = + input.convoyTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) || 'convoy'; + const featureBranch = `convoy/${convoySlug}/${convoyId.slice(0, 8)}/head`; + + // 1. Validate the dependency graph has no cycles BEFORE persisting anything. + // Kahn's algorithm: if we can't visit all nodes, there's a cycle. + { + const adj = new Map(); + const inDegree = new Map(); + for (let i = 0; i < input.tasks.length; i++) { + adj.set(i, []); + inDegree.set(i, 0); + } + for (let i = 0; i < input.tasks.length; i++) { + for (const depIdx of input.tasks[i].depends_on ?? []) { + if (depIdx < 0 || depIdx >= input.tasks.length || depIdx === i) continue; + (adj.get(depIdx) ?? []).push(i); + inDegree.set(i, (inDegree.get(i) ?? 0) + 1); + } + } + const queue: number[] = []; + for (const [node, deg] of inDegree) { + if (deg === 0) queue.push(node); + } + let visited = 0; + while (queue.length > 0) { + const node = queue.shift(); + if (node === undefined) break; + visited++; + for (const neighbor of adj.get(node) ?? []) { + const newDeg = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + if (visited < input.tasks.length) { + throw new Error( + `Convoy dependency graph contains a cycle — ${input.tasks.length - visited} tasks are involved in circular dependencies` + ); + } + } + + // 2. Create convoy bead + convoy_metadata + query( + this.sql, + /* sql */ ` + INSERT INTO ${beads} ( + ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, + ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, + ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, + ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, + ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, + ${beads.columns.closed_at} + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + convoyId, + 'convoy', + 'open', + input.convoyTitle, + null, // body + null, // rig_id — intentionally null; a convoy is a town-level grouping that can span multiple rigs + null, // parent_bead_id + null, // assignee_agent_bead_id + 'medium', + JSON.stringify(['gt:convoy']), + JSON.stringify({ feature_branch: featureBranch }), + null, + timestamp, + timestamp, + null, + ] + ); + + const mergeMode = input.merge_mode ?? 'review-then-land'; + + query( + this.sql, + /* sql */ ` + INSERT INTO ${convoy_metadata} ( + ${convoy_metadata.columns.bead_id}, ${convoy_metadata.columns.total_beads}, + ${convoy_metadata.columns.closed_beads}, ${convoy_metadata.columns.landed_at}, + ${convoy_metadata.columns.feature_branch}, ${convoy_metadata.columns.merge_mode} + ) VALUES (?, ?, ?, ?, ?, ?) + `, + [convoyId, input.tasks.length, 0, null, featureBranch, mergeMode] + ); + + // 2. Create all beads and track their IDs (needed for depends_on resolution) + const beadIds: string[] = []; + const results: Array<{ bead: Bead; agent: Agent }> = []; + + for (const task of input.tasks) { + const createdBead = beadOps.createBead(this.sql, { + type: 'issue', + title: task.title, + body: task.body, + priority: 'medium', + rig_id: input.rigId, + metadata: { convoy_id: convoyId, feature_branch: featureBranch }, + }); + beadIds.push(createdBead.bead_id); + + // Link bead → convoy via 'tracks' + query( + this.sql, + /* sql */ ` + INSERT INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, ?) + `, + [createdBead.bead_id, convoyId, 'tracks'] + ); + } + + // 4. Create 'blocks' dependencies from depends_on indices + for (let i = 0; i < input.tasks.length; i++) { + const deps = input.tasks[i].depends_on; + if (!deps || deps.length === 0) continue; + for (const depIdx of deps) { + if (depIdx < 0 || depIdx >= beadIds.length || depIdx === i) continue; + query( + this.sql, + /* sql */ ` + INSERT OR IGNORE INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, ?) + `, + [beadIds[i], beadIds[depIdx], 'blocks'] + ); + } + } + + // 4. For each bead: assign a polecat, but only dispatch if unblocked + for (let i = 0; i < beadIds.length; i++) { + const beadId = beadIds[i]; + const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); + agents.hookBead(this.sql, agent.id, beadId); + + const bead = beadOps.getBead(this.sql, beadId); + const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; + if (!bead) continue; + + // Only dispatch beads with no unresolved blockers + if (!beadOps.hasUnresolvedBlockers(this.sql, beadId)) { + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingConvoy: fire-and-forget dispatchAgent failed:`, err) + ); + } else { + console.log( + `${TOWN_LOG} slingConvoy: bead=${beadId} blocked, deferring dispatch until deps close` + ); + } + + results.push({ bead, agent: hookedAgent }); + } + + await this.armAlarmIfNeeded(); + + const convoy = this.getConvoy(convoyId); + if (!convoy) throw new Error('Failed to create convoy'); + return { convoy, beads: results }; + } + + /** + * List active convoys with progress counts. + */ + async listConvoys(): Promise { + await this.ensureInitialized(); + const rows = [ + ...query( + this.sql, + /* sql */ `${CONVOY_JOIN} + WHERE ${beads.status} != 'closed' + ORDER BY ${beads.created_at} DESC`, + [] + ), + ]; + return rows.map(row => toConvoy(ConvoyBeadRecord.parse(row))); + } + + /** + * List active convoys with full per-bead breakdown in a single DO call. + * Avoids N+1 RPC fan-out from calling getConvoyStatus for each convoy. + */ + async listConvoysDetailed(): Promise< + Array< + ConvoyEntry & { + beads: Array<{ + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }>; + dependency_edges: Array<{ + bead_id: string; + depends_on_bead_id: string; + }>; + } + > + > { + await this.ensureInitialized(); + const convoys = await this.listConvoys(); + const detailed = []; + for (const convoy of convoys) { + const status = await this.getConvoyStatus(convoy.id); + detailed.push(status ?? { ...convoy, beads: [], dependency_edges: [] }); + } + return detailed; + } + + /** + * Detailed convoy status with per-bead breakdown and DAG edges. + */ + async getConvoyStatus(convoyId: string): Promise< + | (ConvoyEntry & { + beads: Array<{ + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }>; + dependency_edges: Array<{ + bead_id: string; + depends_on_bead_id: string; + }>; + }) + | null + > { + await this.ensureInitialized(); + const convoy = this.getConvoy(convoyId); + if (!convoy) return null; + + // Fetch tracked beads with optional agent name. + // Both sides of the LEFT JOIN are the beads table, so all column refs + // must be qualified to avoid ambiguity. + const trackedRows = [ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.bead_id}, ${beads.title}, ${beads.status}, + ${beads.rig_id}, + ${beads.assignee_agent_bead_id}, + agent_beads.${beads.columns.title} AS assignee_agent_name + FROM ${bead_dependencies} + INNER JOIN ${beads} ON ${bead_dependencies.bead_id} = ${beads.bead_id} + LEFT JOIN ${beads} AS agent_beads + ON ${beads.assignee_agent_bead_id} = agent_beads.${beads.columns.bead_id} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + ORDER BY ${beads.created_at} ASC + `, + [convoyId] + ), + ]; + + const TrackedBeadRow = z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), + rig_id: z.string().nullable(), + assignee_agent_name: z.string().nullable(), + }); + + // Get DAG edges (blocks dependencies) between tracked beads + const dependencyEdges = beadOps.getConvoyDependencyEdges(this.sql, convoyId); + + return { + ...convoy, + beads: trackedRows.map(row => TrackedBeadRow.parse(row)), + dependency_edges: dependencyEdges, + }; + } + private getConvoy(convoyId: string): ConvoyEntry | null { const rows = [ ...query(this.sql, /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, [convoyId]), @@ -1149,6 +1535,11 @@ export class TownDO extends DurableObject { } catch (err) { console.error(`${TOWN_LOG} alarm: processReviewQueue failed`, err); } + try { + await this.processConvoyLandings(); + } catch (err) { + console.error(`${TOWN_LOG} alarm: processConvoyLandings failed`, err); + } try { await this.reEscalateStaleEscalations(); } catch (err) { @@ -1207,6 +1598,28 @@ export class TownDO extends DurableObject { const townConfig = await this.getTownConfig(); const kilocodeToken = await this.resolveKilocodeToken(); + // Check if this bead belongs to a convoy and resolve its feature branch. + // Convoy beads branch from the feature branch, not from defaultBranch. + const convoyId = beadOps.getConvoyForBead(this.sql, bead.bead_id); + const convoyFeatureBranch = convoyId + ? beadOps.getConvoyFeatureBranch(this.sql, convoyId) + : null; + + // Transition the bead to in_progress BEFORE starting the container. + // This must happen synchronously within the DO's I/O gate — the + // fire-and-forget pattern used by slingBead/slingConvoy means the + // calling RPC may return before startAgentInContainer completes, + // closing the I/O gate and preventing further SQL writes. + const currentBead = beadOps.getBead(this.sql, bead.bead_id); + if ( + currentBead && + currentBead.status !== 'in_progress' && + currentBead.status !== 'closed' && + currentBead.status !== 'failed' + ) { + beadOps.updateBeadStatus(this.sql, bead.bead_id, 'in_progress', agent.id); + } + // Mark dispatch in progress: set last_activity_at so schedulePendingWork // skips this agent while the container start is in flight, and bump // dispatch_attempts for the retry budget. @@ -1238,9 +1651,11 @@ export class TownDO extends DurableObject { kilocodeToken, townConfig, platformIntegrationId: rigConfig.platformIntegrationId, + convoyFeatureBranch: convoyFeatureBranch ?? undefined, }); if (started) { + const timestamp = now(); query( this.sql, /* sql */ ` @@ -1250,7 +1665,7 @@ export class TownDO extends DurableObject { ${agent_metadata.columns.last_activity_at} = ? WHERE ${agent_metadata.bead_id} = ? `, - [now(), agent.id] + [timestamp, agent.id] ); console.log(`${TOWN_LOG} dispatchAgent: started agent=${agent.name}(${agent.id})`); } @@ -1261,6 +1676,37 @@ export class TownDO extends DurableObject { } } + /** + * When a bead closes, find beads that were blocked by it and are now + * fully unblocked (all 'blocks' dependencies resolved). Dispatch their + * assigned agents. + */ + private dispatchUnblockedBeads(closedBeadId: string): void { + const unblockedIds = beadOps.getNewlyUnblockedBeads(this.sql, closedBeadId); + if (unblockedIds.length === 0) return; + + console.log( + `${TOWN_LOG} dispatchUnblockedBeads: ${unblockedIds.length} beads unblocked by ${closedBeadId}` + ); + + for (const beadId of unblockedIds) { + const bead = beadOps.getBead(this.sql, beadId); + if (!bead || bead.status === 'closed' || bead.status === 'failed') continue; + + // Find the agent hooked to this bead + if (!bead.assignee_agent_bead_id) continue; + const agent = agents.getAgent(this.sql, bead.assignee_agent_bead_id); + if (!agent || agent.status !== 'idle') continue; + + this.dispatchAgent(agent, bead).catch(err => + console.error( + `${TOWN_LOG} dispatchUnblockedBeads: fire-and-forget dispatch failed for bead=${beadId}`, + err + ) + ); + } + } + /** * Find idle agents with hooked beads and dispatch them to the container. * Agents whose last_activity_at is within the dispatch cooldown are @@ -1321,6 +1767,13 @@ export class TownDO extends DurableObject { continue; } + // Skip beads that still have unresolved 'blocks' dependencies — + // they'll be dispatched by dispatchUnblockedBeads when their + // blockers close. + if (beadOps.hasUnresolvedBlockers(this.sql, beadId)) { + continue; + } + dispatchTasks.push(async () => { await this.dispatchAgent(agent, bead); }); @@ -1445,6 +1898,7 @@ export class TownDO extends DurableObject { */ private async processReviewQueue(): Promise { reviewQueue.recoverStuckReviews(this.sql); + reviewQueue.closeOrphanedReviewBeads(this.sql); // Poll open PRs created by the 'pr' strategy await this.pollPendingPRs(); @@ -1470,9 +1924,45 @@ export class TownDO extends DurableObject { const mergeStrategy = config.resolveMergeStrategy(townConfig, rigConfig.merge_strategy); const gates = townConfig.refinery?.gates ?? []; + // Resolve the target branch from review_metadata. For convoy beads + // this will be the convoy's feature branch; for standalone beads it's + // the rig's default branch. For convoy landing MRs it's back to default. + const targetBranchRows = z + .object({ target_branch: z.string() }) + .array() + .parse([ + ...query( + this.sql, + /* sql */ ` + SELECT ${review_metadata.target_branch} + FROM ${review_metadata} + WHERE ${review_metadata.bead_id} = ? + `, + [entry.id] + ), + ]); + const targetBranch = targetBranchRows[0]?.target_branch ?? rigConfig.defaultBranch; + + // Check if this MR belongs to a convoy and what the merge mode is. + // For 'review-then-land' convoys, the refinery only reviews and merges + // into the feature branch (using direct strategy regardless of town config), + // because the final land to main happens once ALL beads are done. + // For 'review-and-merge' convoys (and standalone beads), use the normal strategy. + const sourceBeadId = typeof entry.bead_id === 'string' ? entry.bead_id : null; + const convoyId = sourceBeadId ? beadOps.getConvoyForBead(this.sql, sourceBeadId) : null; + const convoyMergeMode = convoyId ? beadOps.getConvoyMergeMode(this.sql, convoyId) : null; + + // For review-then-land convoys targeting the feature branch, always use + // direct merge strategy (the refinery merges the polecat's work into the + // feature branch directly, no PR needed for intermediate steps). + const isConvoyIntermediateMerge = + convoyMergeMode === 'review-then-land' && targetBranch !== rigConfig.defaultBranch; + const effectiveMergeStrategy = isConvoyIntermediateMerge ? 'direct' : mergeStrategy; + console.log( `${TOWN_LOG} processReviewQueue: entry=${entry.id} branch=${entry.branch} ` + - `mergeStrategy=${mergeStrategy} gates=${gates.length}` + `targetBranch=${targetBranch} mergeStrategy=${effectiveMergeStrategy} ` + + `convoyMode=${convoyMergeMode ?? 'standalone'} gates=${gates.length}` ); // Always spawn a refinery agent — it handles quality gates (if any), @@ -1486,9 +1976,15 @@ export class TownDO extends DurableObject { townId: this.townId, gates, branch: entry.branch, - targetBranch: rigConfig.defaultBranch, + targetBranch, polecatAgentId: entry.agent_id, - mergeStrategy, + mergeStrategy: effectiveMergeStrategy, + convoyContext: convoyMergeMode + ? { + mergeMode: convoyMergeMode, + isIntermediateStep: isConvoyIntermediateMerge, + } + : undefined, }); // Hook the refinery to the MR bead (entry.id), not the source bead @@ -1505,10 +2001,13 @@ export class TownDO extends DurableObject { role: 'refinery', identity: refineryAgent.identity, beadId: entry.id, - beadTitle: `Review merge: ${entry.branch} → ${rigConfig.defaultBranch}`, + beadTitle: `Review merge: ${entry.branch} → ${targetBranch}`, beadBody: entry.summary ?? '', checkpoint: null, gitUrl: rigConfig.gitUrl, + // Always clone from the rig's real default branch. The targetBranch + // may be a convoy feature branch that doesn't exist on the remote yet. + // The refinery's system prompt tells it which branch to merge into. defaultBranch: rigConfig.defaultBranch, kilocodeToken: rigConfig.kilocodeToken, townConfig, @@ -1525,6 +2024,162 @@ export class TownDO extends DurableObject { } } + /** + * Process convoys whose tracked beads are all closed and that have a + * feature branch waiting to be landed. Creates a final merge_request bead + * to merge the convoy's feature branch into the default branch. + */ + private async processConvoyLandings(): Promise { + // Find convoys with ready_to_land flag in metadata that are still open + const ReadyConvoyRow = z.object({ + bead_id: z.string(), + metadata: z + .string() + .transform(v => { + try { + return JSON.parse(v) as Record; + } catch { + return {}; + } + }) + .pipe(z.record(z.string(), z.any())), + }); + const readyRows = ReadyConvoyRow.array().parse([ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.bead_id}, ${beads.metadata} + FROM ${beads} + WHERE ${beads.type} = 'convoy' + AND ${beads.status} = 'open' + AND json_extract(${beads.metadata}, '$.ready_to_land') = 1 + `, + [] + ), + ]); + + for (const row of readyRows) { + const convoyId = row.bead_id; + const featureBranch = beadOps.getConvoyFeatureBranch(this.sql, convoyId); + if (!featureBranch) continue; + + // Check if there's already a pending landing MR for this convoy + const existingLanding = [ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.bead_id} + FROM ${beads} + WHERE ${beads.type} = 'merge_request' + AND ${beads.status} IN ('open', 'in_progress') + AND json_extract(${beads.metadata}, '$.convoy_landing') = 1 + AND json_extract(${beads.metadata}, '$.convoy_id') = ? + LIMIT 1 + `, + [convoyId] + ), + ]; + if (existingLanding.length > 0) continue; + + // Find which rig this convoy's beads belong to + const rigRow = z + .object({ rig_id: z.string().nullable() }) + .array() + .parse([ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.rig_id} + FROM ${bead_dependencies} + INNER JOIN ${beads} ON ${bead_dependencies.bead_id} = ${beads.bead_id} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + AND ${beads.rig_id} IS NOT NULL + LIMIT 1 + `, + [convoyId] + ), + ]); + const rigId = rigRow[0]?.rig_id; + if (!rigId) continue; + + const rigConfig = await this.getRigConfig(rigId); + if (!rigConfig) continue; + + console.log( + `${TOWN_LOG} processConvoyLandings: creating landing MR for convoy=${convoyId} branch=${featureBranch} → ${rigConfig.defaultBranch}` + ); + + // Submit a landing MR: feature branch → defaultBranch + reviewQueue.submitToReviewQueue(this.sql, { + agent_id: 'system', + bead_id: convoyId, + rig_id: rigId, + branch: featureBranch, + summary: `Landing convoy: merge ${featureBranch} → ${rigConfig.defaultBranch}`, + }); + + // Patch the just-created MR bead's metadata to mark it as a convoy landing + // and set the target_branch to the default branch (not the convoy feature branch). + const mrRows = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + this.sql, + /* sql */ ` + SELECT ${beads.bead_id} + FROM ${beads} + WHERE ${beads.type} = 'merge_request' + AND ${beads.created_by} = 'system' + AND json_extract(${beads.metadata}, '$.source_bead_id') = ? + ORDER BY ${beads.created_at} DESC + LIMIT 1 + `, + [convoyId] + ), + ]); + if (mrRows.length > 0) { + const mrBeadId = mrRows[0].bead_id; + query( + this.sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set( + COALESCE(${beads.metadata}, '{}'), + '$.convoy_landing', 1, + '$.convoy_id', ? + ) + WHERE ${beads.bead_id} = ? + `, + [convoyId, mrBeadId] + ); + // Override the target_branch to the default branch for the landing MR + query( + this.sql, + /* sql */ ` + UPDATE ${review_metadata} + SET ${review_metadata.columns.target_branch} = ? + WHERE ${review_metadata.bead_id} = ? + `, + [rigConfig.defaultBranch, mrBeadId] + ); + } + + // Clear the ready_to_land flag + query( + this.sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_remove(COALESCE(${beads.metadata}, '{}'), '$.ready_to_land'), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [now(), convoyId] + ); + } + } + /** * Poll external PRs created by the 'pr' merge strategy. * Checks if PRs have been merged or closed and updates the MR bead status. diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index 890ca26d0a..8f43289fcf 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -247,12 +247,14 @@ export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void [beadId, now(), agentId] ); + // Assign the agent to the bead but keep the bead as 'open'. + // The bead transitions to 'in_progress' only when the agent's + // container process actually starts (in dispatchAgent). query( sql, /* sql */ ` UPDATE ${beads} - SET ${beads.columns.status} = 'in_progress', - ${beads.columns.assignee_agent_bead_id} = ?, + SET ${beads.columns.assignee_agent_bead_id} = ?, ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 4aa991e9cc..098ee77584 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -3,6 +3,7 @@ * After the beads-centric refactor (#441), all object types are beads. */ +import { z } from 'zod'; import { beads, BeadRecord, createTableBeads, getIndexesBeads } from '../../db/tables/beads.table'; import { bead_events, @@ -21,7 +22,11 @@ import { escalation_metadata, createTableEscalationMetadata, } from '../../db/tables/escalation-metadata.table'; -import { convoy_metadata, createTableConvoyMetadata } from '../../db/tables/convoy-metadata.table'; +import { + convoy_metadata, + createTableConvoyMetadata, + migrateConvoyMetadata, +} from '../../db/tables/convoy-metadata.table'; import { query } from '../../util/query.util'; import type { CreateBeadInput, BeadFilter, Bead } from '../../types'; import type { BeadEventType } from '../../db/tables/bead-events.table'; @@ -52,6 +57,15 @@ export function initBeadTables(sql: SqlStorage): void { query(sql, createTableReviewMetadata(), []); query(sql, createTableEscalationMetadata(), []); query(sql, createTableConvoyMetadata(), []); + + // Migrations: add columns to existing tables (idempotent) + for (const stmt of migrateConvoyMetadata()) { + try { + query(sql, stmt, []); + } catch { + // Column already exists — expected after first run + } + } } export function createBead(sql: SqlStorage, input: CreateBeadInput): Bead { @@ -196,11 +210,215 @@ export function updateBeadStatus( newValue: status, }); + // If the bead reached a terminal status and is tracked by a convoy, + // update the convoy's closed_beads counter and auto-land if complete. + if (status === 'closed' || status === 'failed') { + updateConvoyProgress(sql, beadId, timestamp); + } + const updated = getBead(sql, beadId); if (!updated) throw new Error(`Bead ${beadId} not found after update`); return updated; } +/** + * If beadId is tracked by a convoy (via bead_dependencies 'tracks'), + * recount closed beads and update convoy_metadata. Auto-lands the + * convoy when all tracked beads are closed. + */ +function updateConvoyProgress(sql: SqlStorage, beadId: string, timestamp: string): void { + const convoyRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.depends_on_bead_id} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId] + ), + ]; + if (convoyRows.length === 0) return; + + for (const row of convoyRows) { + const convoyId = z.object({ depends_on_bead_id: z.string() }).parse(row).depends_on_bead_id; + + // Skip if this isn't actually a convoy (e.g. MR bead 'tracks' its source bead, + // which may not be a convoy). No convoy_metadata row → not a convoy. + const metaCheck = [ + ...query( + sql, + /* sql */ ` + SELECT 1 FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + if (metaCheck.length === 0) continue; + + // Count tracked beads that are fully done: closed/failed AND have no + // pending merge_request child beads. This prevents marking a convoy as + // ready_to_land while reviews are still in flight. + 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] + ); + + // Check if convoy should auto-land + const metaRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${convoy_metadata.total_beads} + FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + const totalBeads = z.object({ total_beads: z.number() }).parse(metaRows[0]).total_beads; + + if (closedCount >= totalBeads && totalBeads > 0) { + // For review-then-land convoys with a feature branch, don't auto-close + // the convoy yet — it needs a final merge of the feature branch into + // main. For review-and-merge convoys (where each bead already landed + // independently), auto-close immediately. + const featureBranch = getConvoyFeatureBranch(sql, convoyId); + const mergeMode = getConvoyMergeMode(sql, convoyId); + + if (featureBranch && mergeMode === 'review-then-land') { + // Mark the convoy as ready to land by storing a flag in metadata. + // The alarm loop's processReviewQueue will detect this and create + // the final landing MR (feature branch → main). + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.ready_to_land', 1), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [timestamp, convoyId] + ); + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.closed_beads} = ? + WHERE ${convoy_metadata.bead_id} = ? + `, + [closedCount, convoyId] + ); + } else { + // No feature branch — auto-land immediately (backwards compatible) + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.status} = 'closed', + ${beads.columns.closed_at} = ?, + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [timestamp, timestamp, convoyId] + ); + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.landed_at} = ? + WHERE ${convoy_metadata.bead_id} = ? + `, + [timestamp, convoyId] + ); + } + } + } +} + +/** + * Check if a bead has unresolved 'blocks' dependencies — i.e. beads + * that must close before this bead can be dispatched. + */ +export function hasUnresolvedBlockers(sql: SqlStorage, beadId: string): boolean { + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(1) AS count + FROM ${bead_dependencies} + INNER JOIN ${beads} ON ${bead_dependencies.depends_on_bead_id} = ${beads.bead_id} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'blocks' + AND ${beads.status} NOT IN ('closed', 'failed') + `, + [beadId] + ), + ]; + return z.object({ count: z.number() }).parse(rows[0]).count > 0; +} + +/** + * Find beads that were blocked by `closedBeadId` and are now fully unblocked + * (all their 'blocks' dependencies are resolved). + */ +export function getNewlyUnblockedBeads(sql: SqlStorage, closedBeadId: string): string[] { + // Find beads that depend on the closed bead via 'blocks' + const dependentRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.bead_id} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'blocks' + `, + [closedBeadId] + ), + ]; + + const dependentIds = z + .object({ bead_id: z.string() }) + .array() + .parse(dependentRows) + .map(r => r.bead_id); + + // For each dependent, check if ALL blockers are now resolved + return dependentIds.filter(id => !hasUnresolvedBlockers(sql, id)); +} + export function closeBead(sql: SqlStorage, beadId: string, agentId: string): Bead { return updateBeadStatus(sql, beadId, 'closed', agentId); } @@ -330,3 +548,166 @@ export function listBeadEvents( ]; return BeadEventRecord.array().parse(rows); } + +// ── Bead Dependencies (DAG queries) ───────────────────────────────── + +/** + * Return all dependency edges for a given bead (both directions). + * - blockers: beads that block this bead (this bead depends_on them) + * - blocked_by_this: beads that this bead blocks (they depend_on this bead) + * - tracks: convoys this bead is tracked by + */ +export function getBeadDependencies( + sql: SqlStorage, + beadId: string +): { + blockers: Array<{ bead_id: string; depends_on_bead_id: string; dependency_type: string }>; + dependents: Array<{ bead_id: string; depends_on_bead_id: string; dependency_type: string }>; +} { + const DependencyRow = z.object({ + bead_id: z.string(), + depends_on_bead_id: z.string(), + dependency_type: z.string(), + }); + + // Forward: beads this bead depends on (its blockers / the convoys it tracks) + const blockerRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.bead_id}, ${bead_dependencies.depends_on_bead_id}, + ${bead_dependencies.dependency_type} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + `, + [beadId] + ), + ]; + + // Reverse: beads that depend on this bead + const dependentRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.bead_id}, ${bead_dependencies.depends_on_bead_id}, + ${bead_dependencies.dependency_type} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.depends_on_bead_id} = ? + `, + [beadId] + ), + ]; + + return { + blockers: DependencyRow.array().parse(blockerRows), + dependents: DependencyRow.array().parse(dependentRows), + }; +} + +/** + * Return all 'blocks' dependency edges for beads tracked by a convoy. + * Used to render the DAG in the convoy UI. + */ +export function getConvoyDependencyEdges( + sql: SqlStorage, + convoyId: string +): Array<{ bead_id: string; depends_on_bead_id: string }> { + const EdgeRow = z.object({ + bead_id: z.string(), + depends_on_bead_id: z.string(), + }); + + // First get all bead IDs tracked by this convoy + // Then get all 'blocks' edges between those beads + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT dep.${bead_dependencies.columns.bead_id}, + dep.${bead_dependencies.columns.depends_on_bead_id} + FROM ${bead_dependencies} AS dep + WHERE dep.${bead_dependencies.columns.dependency_type} = 'blocks' + AND dep.${bead_dependencies.columns.bead_id} IN ( + SELECT tracked.${bead_dependencies.columns.bead_id} + FROM ${bead_dependencies} AS tracked + WHERE tracked.${bead_dependencies.columns.depends_on_bead_id} = ? + AND tracked.${bead_dependencies.columns.dependency_type} = 'tracks' + ) + AND dep.${bead_dependencies.columns.depends_on_bead_id} IN ( + SELECT tracked2.${bead_dependencies.columns.bead_id} + FROM ${bead_dependencies} AS tracked2 + WHERE tracked2.${bead_dependencies.columns.depends_on_bead_id} = ? + AND tracked2.${bead_dependencies.columns.dependency_type} = 'tracks' + ) + `, + [convoyId, convoyId] + ), + ]; + + return EdgeRow.array().parse(rows); +} + +/** + * Find the convoy a bead belongs to (if any) via 'tracks' dependencies. + * Returns the convoy bead_id or null. + */ +export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null { + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT ${bead_dependencies.depends_on_bead_id} + FROM ${bead_dependencies} + WHERE ${bead_dependencies.bead_id} = ? + AND ${bead_dependencies.dependency_type} = 'tracks' + `, + [beadId] + ), + ]; + if (rows.length === 0) return null; + return z.object({ depends_on_bead_id: z.string() }).parse(rows[0]).depends_on_bead_id; +} + +/** + * Get the merge_mode for a convoy from convoy_metadata. + * Defaults to 'review-then-land' if not set. + */ +export function getConvoyMergeMode( + sql: SqlStorage, + convoyId: string +): 'review-then-land' | 'review-and-merge' { + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT ${convoy_metadata.merge_mode} + FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + if (rows.length === 0) return 'review-then-land'; + const mode = z.object({ merge_mode: z.string().nullable() }).parse(rows[0]).merge_mode; + if (mode === 'review-and-merge') return 'review-and-merge'; + return 'review-then-land'; +} + +/** + * Get the feature_branch for a convoy from convoy_metadata. + */ +export function getConvoyFeatureBranch(sql: SqlStorage, convoyId: string): string | null { + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT ${convoy_metadata.feature_branch} + FROM ${convoy_metadata} + WHERE ${convoy_metadata.bead_id} = ? + `, + [convoyId] + ), + ]; + if (rows.length === 0) return null; + return z.object({ feature_branch: z.string().nullable() }).parse(rows[0]).feature_branch; +} diff --git a/cloudflare-gastown/src/dos/town/container-dispatch.ts b/cloudflare-gastown/src/dos/town/container-dispatch.ts index 5980a430b9..8a71016b52 100644 --- a/cloudflare-gastown/src/dos/town/container-dispatch.ts +++ b/cloudflare-gastown/src/dos/town/container-dispatch.ts @@ -118,6 +118,33 @@ export function branchForAgent(name: string, beadId?: string): string { return `gt/${slug}${beadSuffix}`; } +/** + * Generate a branch name for a convoy bead's agent. + * + * Agent branches are siblings of the convoy feature branch's /head ref, + * not children of it. Git refs are file-based: a ref at path X blocks + * refs under X/. The convoy feature branch ends with /head (a leaf), + * and agent branches sit alongside it under the same convoy prefix: + * + * convoy///head ← feature branch + * convoy///gt// ← agent branch (sibling) + * + * Both are entries within the / directory, so no ref conflict. + */ +export function branchForConvoyAgent( + convoyFeatureBranch: string, + name: string, + beadId: string +): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + // Strip /head suffix to get the convoy prefix, then place the agent branch as a sibling + const convoyPrefix = convoyFeatureBranch.replace(/\/head$/, ''); + return `${convoyPrefix}/gt/${slug}/${beadId.slice(0, 8)}`; +} + /** * Signal the container to start an agent process. * Attaches current town config via X-Town-Config header. @@ -143,6 +170,8 @@ export async function startAgentInContainer( townConfig: TownConfig; systemPromptOverride?: string; platformIntegrationId?: string; + /** For convoy beads: the convoy's feature branch to branch from instead of defaultBranch. */ + convoyFeatureBranch?: string; } ): Promise { console.log( @@ -220,7 +249,13 @@ export async function startAgentInContainer( townId: params.townId, }), gitUrl: params.gitUrl, - branch: branchForAgent(params.agentName, params.beadId), + branch: params.convoyFeatureBranch + ? branchForConvoyAgent(params.convoyFeatureBranch, params.agentName, params.beadId) + : branchForAgent(params.agentName, params.beadId), + // Always use the rig's real default branch for the initial git clone. + // The convoy feature branch may not exist on the remote yet (the first + // agent's work creates it via the refinery merge). The agent's working + // branch is created as a worktree from HEAD after clone. defaultBranch: params.defaultBranch, envVars, platformIntegrationId: params.platformIntegrationId, diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 991751180d..381deb2f56 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -11,9 +11,20 @@ import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads import { review_metadata } from '../../db/tables/review-metadata.table'; import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; import { agent_metadata } from '../../db/tables/agent-metadata.table'; +import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; import { query } from '../../util/query.util'; -import { logBeadEvent, getBead, closeBead, updateBeadStatus, createBead } from './beads'; +import { + logBeadEvent, + getBead, + closeBead, + updateBeadStatus, + createBead, + getConvoyForBead, + getConvoyFeatureBranch, + getConvoyMergeMode, +} from './beads'; import { getAgent, unhookBead } from './agents'; +import { getRig } from './rigs'; import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; // Review entries stuck in 'running' past this timeout are reset to 'pending' @@ -86,6 +97,26 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v metadata.pr_url = input.pr_url; } + // Resolve the target branch for this MR: + // - For review-then-land convoy beads → convoy's feature branch + // - For review-and-merge convoy beads → rig's default branch (land independently) + // - For standalone beads → rig's default branch + // We pass defaultBranch from the caller so we don't hardcode 'main'. + const convoyId = getConvoyForBead(sql, input.bead_id); + const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; + const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; + const targetBranch = + convoyMergeMode === 'review-then-land' && convoyFeatureBranch + ? convoyFeatureBranch + : (input.default_branch ?? 'main'); + + if (convoyId) { + metadata.convoy_id = convoyId; + if (convoyFeatureBranch) { + metadata.convoy_feature_branch = convoyFeatureBranch; + } + } + // Create the merge_request bead query( sql, @@ -141,7 +172,7 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.branch, 'main', null, input.pr_url ?? null, 0] + [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] ); logBeadEvent(sql, { @@ -149,7 +180,7 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v agentId: input.agent_id, eventType: 'review_submitted', newValue: input.branch, - metadata: { branch: input.branch }, + metadata: { branch: input.branch, target_branch: targetBranch }, }); } @@ -243,6 +274,20 @@ export function completeReviewWithResult( if (input.status === 'merged') { closeBead(sql, entry.bead_id, entry.agent_id); + + // If this was a convoy landing MR, also set landed_at on the convoy metadata + const sourceBead = getBead(sql, entry.bead_id); + if (sourceBead?.type === 'convoy') { + query( + sql, + /* sql */ ` + UPDATE ${convoy_metadata} + SET ${convoy_metadata.columns.landed_at} = ? + WHERE ${convoy_metadata.bead_id} = ? + `, + [now(), entry.bead_id] + ); + } } else if (input.status === 'conflict') { // Create an escalation bead so the conflict is visible and actionable createBead(sql, { @@ -359,6 +404,59 @@ export function recoverStuckReviews(sql: SqlStorage): void { ); } +/** + * Close MR beads that are stuck waiting for a PR review but whose assigned + * agent is no longer active. After a container restart, agents lose their + * in-memory state — the PR review will never complete. Close these beads + * so they don't block convoy progress indefinitely. + * + * Only affects beads with a pr_url (excluded by recoverStuckReviews) that + * are stale (>30 min) and whose agent is idle/dead/missing. + */ +const ORPHAN_REVIEW_TIMEOUT_MS = 30 * 60 * 1000; + +export function closeOrphanedReviewBeads(sql: SqlStorage): void { + const cutoff = new Date(Date.now() - ORPHAN_REVIEW_TIMEOUT_MS).toISOString(); + + const orphanRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${beads.bead_id}, ${beads.assignee_agent_bead_id} + FROM ${beads} + INNER JOIN ${review_metadata} ON ${beads.bead_id} = ${review_metadata.bead_id} + LEFT JOIN ${agent_metadata} ON ${beads.assignee_agent_bead_id} = ${agent_metadata.bead_id} + WHERE ${beads.type} = 'merge_request' + AND ${beads.status} = 'open' + AND ${review_metadata.pr_url} IS NOT NULL + AND ${beads.updated_at} < ? + AND ( + ${agent_metadata.bead_id} IS NULL + OR ${agent_metadata.status} IN ('idle', 'dead') + ) + `, + [cutoff] + ), + ]; + + for (const row of orphanRows) { + const parsed = z + .object({ bead_id: z.string(), assignee_agent_bead_id: z.string().nullable() }) + .parse(row); + try { + closeBead(sql, parsed.bead_id, parsed.assignee_agent_bead_id ?? 'system'); + console.log( + `[review-queue] closeOrphanedReviewBeads: closed orphaned MR bead=${parsed.bead_id}` + ); + } catch (err) { + console.warn( + `[review-queue] closeOrphanedReviewBeads: failed to close bead=${parsed.bead_id}`, + err + ); + } + } +} + // ── Agent Done ────────────────────────────────────────────────────── export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { @@ -421,13 +519,19 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu ); } + // Resolve the rig's default branch so submitToReviewQueue can use it + // instead of hardcoding 'main' for standalone/review-and-merge beads. + const rigId = agent.rig_id ?? ''; + const rig = rigId ? getRig(sql, rigId) : null; + submitToReviewQueue(sql, { agent_id: agentId, bead_id: sourceBead, - rig_id: agent.rig_id ?? '', + rig_id: rigId, branch: input.branch, pr_url: input.pr_url, summary: input.summary, + default_branch: rig?.default_branch, }); // Close the source bead (matches upstream gt done behavior). The polecat's diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 3f7425ec83..5dabb37373 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -75,10 +75,13 @@ import { } from './handlers/mayor.handler'; import { handleMayorSling, + handleMayorSlingBatch, handleMayorListRigs, handleMayorListBeads, handleMayorListAgents, handleMayorSendMail, + handleMayorListConvoys, + handleMayorConvoyStatus, } from './handlers/mayor-tools.handler'; import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; import { handleGetTownConfig, handleUpdateTownConfig } from './handlers/town-config.handler'; @@ -385,12 +388,17 @@ app.post('/api/towns/:townId/mayor/destroy', c => handleDestroyMayor(c, c.req.pa app.use('/api/mayor/:townId/tools/*', mayorAuthMiddleware); app.post('/api/mayor/:townId/tools/sling', c => handleMayorSling(c, c.req.param())); +app.post('/api/mayor/:townId/tools/sling-batch', c => handleMayorSlingBatch(c, c.req.param())); app.get('/api/mayor/:townId/tools/rigs', c => handleMayorListRigs(c, c.req.param())); app.get('/api/mayor/:townId/tools/rigs/:rigId/beads', c => handleMayorListBeads(c, c.req.param())); app.get('/api/mayor/:townId/tools/rigs/:rigId/agents', c => handleMayorListAgents(c, c.req.param()) ); app.post('/api/mayor/:townId/tools/mail', c => handleMayorSendMail(c, c.req.param())); +app.get('/api/mayor/:townId/tools/convoys', c => handleMayorListConvoys(c, c.req.param())); +app.get('/api/mayor/:townId/tools/convoys/:convoyId', c => + handleMayorConvoyStatus(c, c.req.param()) +); // ── tRPC ──────────────────────────────────────────────────────────────── // Serve the gastown tRPC router directly. The frontend tRPC client diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index e73f5cb142..663559f651 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -18,6 +18,22 @@ const MayorSlingBody = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }); +const MayorSlingBatchBody = z.object({ + rig_id: z.string().min(1), + convoy_title: z.string().min(1), + tasks: z + .array( + z.object({ + title: z.string().min(1), + body: z.string().optional(), + depends_on: z.array(z.number().int().min(0)).optional(), + }) + ) + .min(1) + .max(50), + merge_mode: z.enum(['review-then-land', 'review-and-merge']).optional(), +}); + const MayorMailBody = z.object({ rig_id: z.string().min(1), to_agent_id: z.string().min(1), @@ -220,3 +236,73 @@ export async function handleMayorSendMail(c: Context, params: { town return c.json(resSuccess({ sent: true })); } + +/** + * POST /api/mayor/:townId/tools/sling-batch + * Sling multiple beads as a tracked convoy. Creates N beads + 1 convoy, + * assigns polecats, and dispatches all in one call. + */ +export async function handleMayorSlingBatch(c: Context, params: { townId: string }) { + const parsed = MayorSlingBatchBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + const rigOwned = await verifyRigBelongsToTown(c, params.townId, parsed.data.rig_id); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + console.log( + `${HANDLER_LOG} handleMayorSlingBatch: townId=${params.townId} rigId=${parsed.data.rig_id} convoy="${parsed.data.convoy_title.slice(0, 80)}" tasks=${parsed.data.tasks.length}` + ); + + const town = getTownDOStub(c.env, params.townId); + const result = await town.slingConvoy({ + rigId: parsed.data.rig_id, + convoyTitle: parsed.data.convoy_title, + tasks: parsed.data.tasks, + merge_mode: parsed.data.merge_mode, + }); + + console.log( + `${HANDLER_LOG} handleMayorSlingBatch: completed, convoy=${result.convoy.id} beads=${result.beads.length}` + ); + + return c.json(resSuccess(result), 201); +} + +/** + * GET /api/mayor/:townId/tools/convoys + * List active convoys with progress counts. + */ +export async function handleMayorListConvoys(c: Context, params: { townId: string }) { + console.log(`${HANDLER_LOG} handleMayorListConvoys: townId=${params.townId}`); + + const town = getTownDOStub(c.env, params.townId); + const convoys = await town.listConvoys(); + + return c.json(resSuccess(convoys)); +} + +/** + * GET /api/mayor/:townId/tools/convoys/:convoyId + * Detailed convoy status with per-bead breakdown. + */ +export async function handleMayorConvoyStatus( + c: Context, + params: { townId: string; convoyId: string } +) { + console.log( + `${HANDLER_LOG} handleMayorConvoyStatus: townId=${params.townId} convoyId=${params.convoyId}` + ); + + const town = getTownDOStub(c.env, params.townId); + const status = await town.getConvoyStatus(params.convoyId); + + if (!status) return c.json(resError('Convoy not found'), 404); + return c.json(resSuccess(status)); +} diff --git a/cloudflare-gastown/src/handlers/rig-beads.handler.ts b/cloudflare-gastown/src/handlers/rig-beads.handler.ts index 615fe24786..0daf6011e4 100644 --- a/cloudflare-gastown/src/handlers/rig-beads.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-beads.handler.ts @@ -17,7 +17,6 @@ const CreateBeadBody = z.object({ labels: z.array(z.string()).optional(), metadata: z.record(z.string(), z.unknown()).optional(), assignee_agent_id: z.string().optional(), - convoy_id: z.string().optional(), }); const UpdateBeadStatusBody = z.object({ diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index 3f1b0e3b56..70974ed03e 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -17,51 +17,129 @@ You are NOT a worker. You do not write code, run tests, or make commits. You are ## YOUR PRIMARY JOB: SLING WORK -Your #1 purpose is to turn user requests into actionable work items via gt_sling. Every time a user describes something that needs to happen in code — a bug fix, feature, refactor, test, doc update, config change, anything — you MUST call gt_sling to create a bead and dispatch a polecat. +Your #1 purpose is to turn user requests into actionable work items. Every time a user describes something that needs to happen in code — a bug fix, feature, refactor, test, doc update, config change, anything — you MUST call gt_sling_batch (for multi-bead tasks) or gt_sling (for single tasks) to create beads and dispatch polecats. -**If you respond to a work request without calling gt_sling, you have failed at your job.** Talking about what could be done is worthless. Slinging the work IS the job. +**If you respond to a work request without slinging, you have failed at your job.** Talking about what could be done is worthless. Slinging the work IS the job. ## Available Tools You have these tools for cross-rig coordination: -- **gt_sling** — YOUR MOST IMPORTANT TOOL. Delegate a task to a polecat in a specific rig. Provide the rig_id, a clear title, and a detailed body with requirements. A polecat will be automatically dispatched to work on it. USE THIS AGGRESSIVELY. +- **gt_sling** — Delegate a single task to a polecat in a specific rig. Use for one-off tasks. +- **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. - **gt_list_rigs** — List all rigs in your town. Returns rig ID, name, git URL, and default branch. Call this first when you need to know what repositories are available. - **gt_list_beads** — List beads (work items) in a rig. Filter by status or type. Use this to check progress, find open work, or review completed tasks. - **gt_list_agents** — List agents in a rig. Shows who is working, idle, or stuck. Use this to understand workforce capacity. +- **gt_list_convoys** — List active convoys with progress counts. Use to check on batched work. +- **gt_convoy_status** — Show detailed status of a convoy: each tracked bead with its status and assignee. Use for progress reports. - **gt_mail_send** — Send a message to any agent in any rig. Use for coordination, follow-up instructions, or status checks. -## Task Decomposition — SPLIT WORK UP +## Task Decomposition — USE CONVOYS -This is critical. A single polecat works on a single bead. Large, vague tasks will fail. Your job is to decompose user requests into focused, independent units of work. +This is critical. A single polecat works on a single bead. Large, vague tasks will fail. Your job is to decompose user requests into focused, independent units of work — and group them into a convoy. + +**When to use gt_sling_batch vs gt_sling:** +- **gt_sling_batch** (preferred): When a request needs 2+ beads. Groups them into a convoy with automatic progress tracking. Convoys land automatically when all tracked beads close. +- **gt_sling**: Only for truly standalone, one-off tasks that don't relate to other beads. + +**Choosing merge_mode:** +- **"review-then-land"** (default): Use for tightly coupled tasks where beads build on each other's code, such as implementing a feature across multiple files, or a series of steps that share context. All work accumulates on a feature branch and lands to main as one cohesive unit at the end. +- **"review-and-merge"**: Use for loosely coupled tasks where each bead is self-contained and can land independently, such as adding unrelated utility functions, fixing separate bugs, or updating different docs. Each bead creates its own PR or merge as soon as it's reviewed. **Rules for splitting:** -1. **One concern per sling.** Each gt_sling call should target one file, one component, one endpoint, or one logical change. If you find yourself writing "and also" in the body, split it. -2. **Parallel by default.** Sling multiple beads at once. Polecats work in parallel — exploit this. A user says "add authentication to the API" → sling separately: auth middleware, login endpoint, signup endpoint, password reset, tests. -3. **Err on the side of more beads.** 5 focused beads that each succeed is infinitely better than 1 mega-bead that gets confused. Polecats are cheap. Sling liberally. -4. **Describe dependencies in the body**, but don't try to sequence them — the system handles dispatch. Just note in each bead's body what it can assume exists. -5. **Never sling a bead with a title like "Implement feature X".** That's too vague. "Add POST /api/users endpoint with email validation" is a sling. "Implement user management" is not. +1. **One concern per task.** Each task should target one file, one component, one endpoint, or one logical change. If you find yourself writing "and also" in the body, split it. +2. **Err on the side of more beads.** 5 focused beads that each succeed is infinitely better than 1 mega-bead that gets confused. Polecats are cheap. Sling liberally. +3. **Never sling a bead with a title like "Implement feature X".** That's too vague. "Add POST /api/users endpoint with email validation" is a sling. "Implement user management" is not. + +## depends_on — THE MOST IMPORTANT PART OF CONVOY PLANNING + +**YOU MUST express dependencies between beads using depends_on.** This is not optional. A convoy without depends_on is almost always wrong. Without depends_on, ALL beads dispatch simultaneously — polecats step on each other's work, write conflicting code, and produce merge conflicts. + +**The system uses depends_on to:** +- Hold back blocked beads until their dependencies complete +- Dispatch beads in the correct order +- Ensure each polecat builds on top of the previous polecat's work + +**Default assumption: most beads need depends_on.** Only omit depends_on when tasks are truly independent — they touch completely different files, have no shared state, and their output doesn't affect each other. This is RARE for feature work. + +**How to think about it:** Before slinging, ask yourself: "If this bead's polecat starts before bead X finishes, will it have the files/code/context it needs?" If the answer is no, add depends_on. + +**Common patterns:** +- **Foundation first:** Scaffolding, schemas, config → everything else depends on these +- **API before UI:** Backend endpoints → frontend components that call them +- **Code before tests:** Implementation → integration tests that test the implementation +- **Serial by default for features:** When building a feature, each step usually builds on the previous. Use a chain: [0] → [1] → [2]. Only parallelize when steps genuinely touch different things. + +Each task declares depends_on as zero-based indices: \`depends_on: [0, 2]\` means "this task needs tasks 0 and 2 to finish first." The system holds blocked tasks until their dependencies close, then dispatches them automatically. + +**CRITICAL: A convoy where every bead has no depends_on means every polecat starts at the same time on the same codebase. Unless the tasks are truly independent (e.g. unrelated utility functions), this WILL cause merge conflicts and failures.** **Example decomposition:** -User says: "We need user authentication with JWT tokens" +User says: "Set up a React + Vite todo app with auth and a REST API" BAD (single vague sling): -→ gt_sling: "Implement user authentication" — this will fail or produce garbage +→ gt_sling: "Create todo app" — this will fail or produce garbage + +BAD (no dependencies — all tasks start at once, but later tasks need the scaffold): +→ gt_sling_batch with 5 tasks, all parallel — tasks 2-5 fail because the project doesn't exist yet + +ALSO BAD (dependencies exist in the developer's head but aren't expressed): +→ gt_sling_batch with 5 tasks, none have depends_on — you KNOW task 4 needs the API from task 1, but you didn't tell the system. Task 4's polecat starts immediately and fails because the API doesn't exist yet. + +GOOD (convoy with DAG dependencies): +→ gt_sling_batch with convoy_title "React Todo App" and tasks: + 0. "Scaffold Vite + React + TypeScript project with Tailwind" (no depends_on — starts immediately) + 1. "Add REST API with Express: GET/POST/PUT/DELETE /api/todos" (depends_on: [0]) + 2. "Add TodoList and TodoItem components with CRUD operations" (depends_on: [0]) + 3. "Add authentication middleware and login page" (depends_on: [0, 1]) + 4. "Add integration tests for API and auth flows" (depends_on: [1, 3]) + +Tasks 1 and 2 both depend on 0 (the scaffold) but NOT on each other — they run in parallel once 0 completes. Task 3 needs both the scaffold and the API. Task 4 needs the API and auth. + +**Example — truly independent (rare):** + +User says: "Add formatCurrency, debounce, and throttle utility functions with tests" + +GOOD (no dependencies needed — all are genuinely independent): +→ gt_sling_batch with convoy_title "Utility Functions" and tasks: + 0. "Add formatCurrency(amount, locale) utility with tests" + 1. "Add debounce(fn, wait) utility with tests" + 2. "Add throttle(fn, limit) utility with tests" + +No depends_on needed — all three touch separate files with no shared state. This is the EXCEPTION, not the rule. + +**Example — serial feature work (common):** + +User says: "Add a user profile page with avatar upload" + +GOOD (serial chain — each step builds on the previous): +→ gt_sling_batch with convoy_title "User Profile Page" and tasks: + 0. "Add user_profiles table migration and Drizzle schema" (no depends_on) + 1. "Add GET/PUT /api/users/:id/profile endpoints" (depends_on: [0]) + 2. "Add avatar upload endpoint with S3 storage" (depends_on: [0]) + 3. "Add ProfilePage component with avatar display and edit form" (depends_on: [1, 2]) + +When in doubt, add the dependency. An unnecessary dependency just means a bead waits a bit longer. A missing dependency means a polecat works on a codebase that's missing the code it needs — and it will fail. + +## Checking on Work — USE CONVOY TOOLS + +When a user asks "how's X going?" or wants a progress update: + +1. Call **gt_list_convoys** first — find the relevant convoy by title. +2. Call **gt_convoy_status** with the convoy_id for a detailed bead-by-bead breakdown. +3. Summarize the progress conversationally: "3 of 5 beads are done. Toast is working on the test update. The middleware fix is in the merge queue." -GOOD (decomposed into focused beads): -→ gt_sling: "Add JWT signing and verification utility in src/lib/auth" -→ gt_sling: "Add POST /api/auth/login endpoint that validates credentials and returns JWT" -→ gt_sling: "Add POST /api/auth/signup endpoint with email/password validation" -→ gt_sling: "Add auth middleware that verifies JWT on protected routes" -→ gt_sling: "Add auth integration tests for login, signup, and protected route access" +Convoys land automatically when all tracked beads close — no manual management needed. ## Conversational Model - **Respond directly for questions.** If the user asks a question you can answer from context, respond conversationally. Don't delegate questions. -- **Delegate via gt_sling for work.** When the user describes work to be done (bugs to fix, features to add, refactoring, etc.), delegate it by calling gt_sling with the appropriate rig. DO NOT just describe what you would do — actually call gt_sling. -- **Non-blocking delegation.** After slinging work, respond immediately to the user. Do NOT wait for the polecat to finish. Summarize what you slung and move on. The user can check progress later. +- **Delegate via gt_sling_batch for work.** When the user describes work to be done (bugs to fix, features to add, refactoring, etc.), delegate it by calling gt_sling_batch (or gt_sling for single tasks) with the appropriate rig. DO NOT just describe what you would do — actually sling it. +- **Non-blocking delegation.** After slinging work, respond immediately to the user. Do NOT wait for the polecat to finish. Summarize what you slung and move on. The user can check progress with gt_list_convoys and gt_convoy_status. - **Discover rigs first.** If you don't know which rig to use, call gt_list_rigs before slinging. - **When in doubt, sling.** If a user's message could be interpreted as a request for work OR a question, treat it as a request for work. @@ -69,7 +147,7 @@ GOOD (decomposed into focused beads): The Gas Town Universal Propulsion Principle: if there is work to be done, do it immediately. When the user asks for something, act on it right away. Don't ask for confirmation unless the request is genuinely ambiguous. Prefer action over clarification. -**GUPP means: the moment you identify work, call gt_sling. Do not summarize the plan first. Do not ask "shall I go ahead?" — just sling it.** +**GUPP means: the moment you identify work, call gt_sling_batch (or gt_sling for a single task). Do not summarize the plan first. Do not ask "shall I go ahead?" — just sling it.** ## Writing Good Sling Titles and Bodies @@ -92,5 +170,5 @@ The polecat works autonomously — it cannot ask you questions mid-task. Front-l - Never fabricate rig IDs or agent IDs. Always use gt_list_rigs to discover real IDs. - If no rigs exist, tell the user they need to create one first. - If a task spans multiple rigs, create separate slings for each rig. -- ALWAYS call gt_sling when the user requests work. Describing what you would do without actually slinging is a failure mode.`; +- ALWAYS sling when the user requests work. Describing what you would do without actually slinging is a failure mode. Prefer gt_sling_batch for multi-task requests.`; } diff --git a/cloudflare-gastown/src/prompts/refinery-system.prompt.ts b/cloudflare-gastown/src/prompts/refinery-system.prompt.ts index 025e832d47..09b934c883 100644 --- a/cloudflare-gastown/src/prompts/refinery-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/refinery-system.prompt.ts @@ -15,6 +15,12 @@ export function buildRefinerySystemPrompt(params: { targetBranch: string; polecatAgentId: string; mergeStrategy: MergeStrategy; + /** Present when this review is for a bead inside a convoy. */ + convoyContext?: { + mergeMode: 'review-then-land' | 'review-and-merge'; + /** True when this is an intermediate step (not the final landing merge). */ + isIntermediateStep: boolean; + }; }): string { const gateList = params.gates.length > 0 @@ -26,6 +32,8 @@ export function buildRefinerySystemPrompt(params: { ? buildDirectMergeInstructions(params) : buildPRMergeInstructions(params); + const convoySection = params.convoyContext ? buildConvoySection(params.convoyContext) : ''; + return `You are the Refinery agent for rig "${params.rigId}" (town "${params.townId}"). Your identity: ${params.identity} @@ -37,6 +45,7 @@ You review code changes from polecat agents and, if they pass review, either mer - **Target branch:** \`${params.targetBranch}\` - **Merge strategy:** ${params.mergeStrategy === 'direct' ? 'Direct merge (you merge and push)' : 'Pull request (you create a PR)'} - **Polecat agent ID:** ${params.polecatAgentId} +${convoySection} ## Review Process @@ -85,6 +94,28 @@ ${mergeInstructions} `; } +function buildConvoySection(ctx: { + mergeMode: 'review-then-land' | 'review-and-merge'; + isIntermediateStep: boolean; +}): string { + if (ctx.mergeMode === 'review-then-land' && ctx.isIntermediateStep) { + return ` +## Convoy Context +This bead is part of a **review-then-land** convoy. Your job for this intermediate step is: +1. **Review the code** — run quality gates and code review as normal. +2. **If approved, merge into the convoy's feature branch** — this is an intermediate merge, NOT the final landing to main. Merge directly into the target branch shown above. +3. **If changes needed, send rework request** — the polecat will fix and resubmit. + +The final merge/PR to main happens automatically once ALL beads in the convoy are done. Do NOT create a PR for this intermediate step.`; + } + if (ctx.mergeMode === 'review-and-merge') { + return ` +## Convoy Context +This bead is part of a **review-and-merge** convoy. Each bead goes through the full review and merge/PR cycle independently. Proceed with your normal review and merge/PR process.`; + } + return ''; +} + function buildDirectMergeInstructions(params: { branch: string; targetBranch: string }): string { return `1. Fetch the latest target branch: \`git fetch origin ${params.targetBranch}\` 2. Check out the target branch: \`git checkout ${params.targetBranch} && git pull origin ${params.targetBranch}\` diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 33c3371758..c4946f816c 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -27,6 +27,7 @@ import { RpcPtySessionOutput, RpcSlingResultOutput, RpcRigDetailOutput, + RpcConvoyDetailOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -571,6 +572,38 @@ export const gastownRouter = router({ limit: input.limit, }); }), + + listConvoys: procedure + .input( + z.object({ + townId: z.string().uuid(), + }) + ) + .output(z.array(RpcConvoyDetailOutput)) + .query(async ({ ctx, input }) => { + requireAdmin(ctx); + await verifyTownOwnership(ctx.env, ctx.userId, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.listConvoysDetailed(); + }), + + closeConvoy: procedure + .input( + z.object({ + townId: z.string().uuid(), + convoyId: z.string().uuid(), + }) + ) + .output(RpcConvoyDetailOutput.nullable()) + .mutation(async ({ ctx, input }) => { + requireAdmin(ctx); + await verifyTownOwnership(ctx.env, ctx.userId, input.townId); + const townStub = getTownDOStub(ctx.env, input.townId); + const convoy = await townStub.closeConvoy(input.convoyId); + if (!convoy) return null; + const status = await townStub.getConvoyStatus(input.convoyId); + return status ?? { ...convoy, beads: [] }; + }), }); export type GastownRouter = typeof gastownRouter; diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 5deface759..e0ae802a3e 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; * (avoiding "excessively deep" instantiation with Rpc.Promisified DO stubs) * while still performing full runtime validation via the piped schema. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any function rpcSafe(schema: T): z.ZodPipe { return z.any().pipe(schema); } @@ -112,6 +111,40 @@ export const PtySessionOutput = z.object({ wsUrl: z.string(), }); +// Convoy summary +export const ConvoyOutput = z.object({ + id: z.string(), + title: z.string(), + status: z.enum(['active', 'landed']), + total_beads: z.number(), + closed_beads: z.number(), + created_by: z.string().nullable(), + created_at: z.string(), + landed_at: z.string().nullable(), + feature_branch: z.string().nullable(), + merge_mode: z.string().nullable(), +}); + +// Detailed convoy status with per-bead breakdown and DAG edges +export const ConvoyDetailOutput = ConvoyOutput.extend({ + beads: z.array( + z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), + rig_id: z.string().nullable(), + assignee_agent_name: z.string().nullable(), + }) + ), + /** 'blocks' dependency edges between tracked beads — the execution DAG. */ + dependency_edges: z.array( + z.object({ + bead_id: z.string(), + depends_on_bead_id: z.string(), + }) + ), +}); + // SlingResult export const SlingResultOutput = z.object({ bead: BeadOutput, @@ -148,5 +181,7 @@ export const RpcMayorSendResultOutput = rpcSafe(MayorSendResultOutput); export const RpcMayorStatusOutput = rpcSafe(MayorStatusOutput); export const RpcStreamTicketOutput = rpcSafe(StreamTicketOutput); export const RpcPtySessionOutput = rpcSafe(PtySessionOutput); +export const RpcConvoyOutput = rpcSafe(ConvoyOutput); +export const RpcConvoyDetailOutput = rpcSafe(ConvoyDetailOutput); export const RpcSlingResultOutput = rpcSafe(SlingResultOutput); export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index d87d7b5e7d..46f7b5b543 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -134,6 +134,8 @@ export type ReviewQueueInput = { branch: string; pr_url?: string; summary?: string; + /** The rig's default branch. Used as target when not overridden by convoy feature branch. */ + default_branch?: string; }; // -- Molecules (now beads with type='molecule' + child step beads) -- @@ -295,6 +297,6 @@ export type AgentConfigOverrides = z.infer; export type { AgentMetadataRecord } from './db/tables/agent-metadata.table'; export type { ReviewMetadataRecord } from './db/tables/review-metadata.table'; export type { EscalationMetadataRecord } from './db/tables/escalation-metadata.table'; -export type { ConvoyMetadataRecord } from './db/tables/convoy-metadata.table'; +export type { ConvoyMetadataRecord, ConvoyMergeMode } from './db/tables/convoy-metadata.table'; export type { BeadEventRecord } from './db/tables/bead-events.table'; export type { BeadDependencyRecord } from './db/tables/bead-dependencies.table'; diff --git a/cloudflare-gastown/test/integration/convoy-dag.test.ts b/cloudflare-gastown/test/integration/convoy-dag.test.ts new file mode 100644 index 0000000000..3586b0f295 --- /dev/null +++ b/cloudflare-gastown/test/integration/convoy-dag.test.ts @@ -0,0 +1,554 @@ +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; + +function getTownStub(name = 'test-town') { + const id = env.TOWN.idFromName(name); + return env.TOWN.get(id); +} + +describe('Convoy DAG and Feature Branches', () => { + let town: ReturnType; + + beforeEach(() => { + town = getTownStub(`convoy-dag-${crypto.randomUUID()}`); + }); + + // ── Feature Branch ───────────────────────────────────────────────── + + describe('feature branch creation', () => { + it('should create a convoy with a feature branch', async () => { + // Need a rig for slingConvoy + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Add User Auth', + tasks: [{ title: 'Create user model' }, { title: 'Add login endpoint' }], + }); + + expect(result.convoy.feature_branch).toBeTruthy(); + expect(result.convoy.feature_branch).toMatch(/^convoy\/add-user-auth\/[0-9a-f]+\/head$/); + expect(result.convoy.status).toBe('active'); + expect(result.convoy.total_beads).toBe(2); + expect(result.beads).toHaveLength(2); + }); + + it('should slug the convoy title for the branch name', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Fix: Bug #123 with special characters!!!', + tasks: [{ title: 'Task 1' }], + }); + + // Should be lowercased, special chars replaced with hyphens, ends with /head + expect(result.convoy.feature_branch).toMatch( + /^convoy\/fix-bug-123-with-special-characters\/[0-9a-f]+\/head$/ + ); + }); + + it('should store convoy_id and feature_branch in bead metadata', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Test Convoy', + tasks: [{ title: 'Task 1' }], + }); + + const bead = await town.getBeadAsync(result.beads[0].bead.bead_id); + expect(bead).toBeTruthy(); + expect(bead?.metadata?.convoy_id).toBe(result.convoy.id); + expect(bead?.metadata?.feature_branch).toBe(result.convoy.feature_branch); + }); + }); + + // ── DAG Dependencies ─────────────────────────────────────────────── + + describe('DAG dependency edges', () => { + it('should create blocks dependencies from depends_on indices', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + // Task 0: no deps + // Task 1: depends on Task 0 + // Task 2: depends on Task 0 and Task 1 + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Serial Convoy', + tasks: [ + { title: 'Step 1' }, + { title: 'Step 2', depends_on: [0] }, + { title: 'Step 3', depends_on: [0, 1] }, + ], + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status).toBeTruthy(); + expect(status!.dependency_edges).toBeDefined(); + expect(status!.dependency_edges.length).toBe(3); // 1→0, 2→0, 2→1 + + const beadIds = result.beads.map(b => b.bead.bead_id); + + // Step 2 (index 1) depends on Step 1 (index 0) + expect(status!.dependency_edges).toContainEqual({ + bead_id: beadIds[1], + depends_on_bead_id: beadIds[0], + }); + + // Step 3 (index 2) depends on Step 1 (index 0) + expect(status!.dependency_edges).toContainEqual({ + bead_id: beadIds[2], + depends_on_bead_id: beadIds[0], + }); + + // Step 3 (index 2) depends on Step 2 (index 1) + expect(status!.dependency_edges).toContainEqual({ + bead_id: beadIds[2], + depends_on_bead_id: beadIds[1], + }); + }); + + it('should return empty dependency_edges for convoys without deps', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Parallel Convoy', + tasks: [{ title: 'Task A' }, { title: 'Task B' }, { title: 'Task C' }], + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status!.dependency_edges).toEqual([]); + }); + + it('should include dependency_edges in listConvoysDetailed', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Detailed Convoy', + tasks: [{ title: 'First' }, { title: 'Second', depends_on: [0] }], + }); + + const detailed = await town.listConvoysDetailed(); + expect(detailed).toHaveLength(1); + expect(detailed[0].dependency_edges).toBeDefined(); + expect(detailed[0].dependency_edges.length).toBe(1); + expect(detailed[0].feature_branch).toBeTruthy(); + }); + }); + + // ── DAG-Aware Scheduling ─────────────────────────────────────────── + + describe('DAG-aware scheduling', () => { + it('should not dispatch blocked beads', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Serial Tasks', + tasks: [ + { title: 'First Step' }, + { title: 'Second Step', depends_on: [0] }, + { title: 'Third Step', depends_on: [1] }, + ], + }); + + // The first bead should be open (ready to dispatch) or in_progress + // The second and third should remain open (blocked) + const bead0 = await town.getBeadAsync(result.beads[0].bead.bead_id); + const bead1 = await town.getBeadAsync(result.beads[1].bead.bead_id); + const bead2 = await town.getBeadAsync(result.beads[2].bead.bead_id); + + // First bead should have an agent hooked and be ready for dispatch + expect(bead0?.assignee_agent_bead_id).toBeTruthy(); + + // Second and third are blocked but still have agents hooked + // (they'll be dispatched when unblocked) + expect(bead1?.assignee_agent_bead_id).toBeTruthy(); + expect(bead2?.assignee_agent_bead_id).toBeTruthy(); + }); + + it('should unblock next bead when blocker closes', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Two Steps', + tasks: [{ title: 'Step 1' }, { title: 'Step 2', depends_on: [0] }], + }); + + const beadIds = result.beads.map(b => b.bead.bead_id); + const agentIds = result.beads.map(b => b.agent.id); + + // Close the first bead — this should unblock the second + await town.updateBeadStatus(beadIds[0], 'closed', agentIds[0]); + + // After closing, check convoy progress + const status = await town.getConvoyStatus(result.convoy.id); + expect(status?.closed_beads).toBe(1); + + // The second bead should still be open/in_progress (it was unblocked) + const bead1 = await town.getBeadAsync(beadIds[1]); + expect(bead1?.status).not.toBe('closed'); + // Its agent should still be hooked + expect(bead1?.assignee_agent_bead_id).toBeTruthy(); + }); + + it('should handle parallel beads that both block a final bead', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + // Diamond shape: A and B run in parallel, C depends on both + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Diamond DAG', + tasks: [{ title: 'Task A' }, { title: 'Task B' }, { title: 'Task C', depends_on: [0, 1] }], + }); + + const beadIds = result.beads.map(b => b.bead.bead_id); + const agentIds = result.beads.map(b => b.agent.id); + + // Close task A — task C should still be blocked (B is open) + await town.updateBeadStatus(beadIds[0], 'closed', agentIds[0]); + + const status1 = await town.getConvoyStatus(result.convoy.id); + expect(status1?.closed_beads).toBe(1); + + // Close task B — task C should now be unblocked + await town.updateBeadStatus(beadIds[1], 'closed', agentIds[1]); + + const status2 = await town.getConvoyStatus(result.convoy.id); + expect(status2?.closed_beads).toBe(2); + }); + }); + + // ── Convoy Progress and Auto-Landing ──────────────────────────────── + + describe('convoy progress', () => { + it('should track closed_beads progress', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Progress Test', + tasks: [{ title: 'Task 1' }, { title: 'Task 2' }, { title: 'Task 3' }], + }); + + // Initially 0 closed + let status = await town.getConvoyStatus(result.convoy.id); + expect(status?.closed_beads).toBe(0); + expect(status?.total_beads).toBe(3); + + // Close one bead + const beadIds = result.beads.map(b => b.bead.bead_id); + const agentIds = result.beads.map(b => b.agent.id); + await town.updateBeadStatus(beadIds[0], 'closed', agentIds[0]); + + status = await town.getConvoyStatus(result.convoy.id); + expect(status?.closed_beads).toBe(1); + + // Close second + await town.updateBeadStatus(beadIds[1], 'closed', agentIds[1]); + + status = await town.getConvoyStatus(result.convoy.id); + expect(status?.closed_beads).toBe(2); + }); + + it('should set ready_to_land when all beads close (with feature branch)', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Landing Test', + tasks: [{ title: 'Only task' }], + }); + + // Convoy should have a feature branch + expect(result.convoy.feature_branch).toBeTruthy(); + + const beadId = result.beads[0].bead.bead_id; + const agentId = result.beads[0].agent.id; + + // Close the only bead + await town.updateBeadStatus(beadId, 'closed', agentId); + + // Convoy should NOT auto-close (it has a feature branch that needs landing) + const status = await town.getConvoyStatus(result.convoy.id); + expect(status?.status).toBe('active'); // Still active, waiting for feature branch landing + expect(status?.closed_beads).toBe(1); + }); + + it('should count failed beads toward convoy progress', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Failure Test', + tasks: [{ title: 'Task 1' }, { title: 'Task 2' }], + }); + + const beadIds = result.beads.map(b => b.bead.bead_id); + const agentIds = result.beads.map(b => b.agent.id); + + // Fail one bead, close the other + await town.updateBeadStatus(beadIds[0], 'failed', agentIds[0]); + await town.updateBeadStatus(beadIds[1], 'closed', agentIds[1]); + + const status = await town.getConvoyStatus(result.convoy.id); + // Both failed and closed count toward progress + expect(status?.closed_beads).toBe(2); + }); + }); + + // ── Force Close ──────────────────────────────────────────────────── + + describe('force close convoy', () => { + it('should close all tracked beads and the convoy', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Force Close Test', + tasks: [{ title: 'Task 1' }, { title: 'Task 2', depends_on: [0] }], + }); + + const closed = await town.closeConvoy(result.convoy.id); + expect(closed?.status).toBe('landed'); + + // All beads should be closed + for (const b of result.beads) { + const bead = await town.getBeadAsync(b.bead.bead_id); + expect(bead?.status).toBe('closed'); + } + }); + }); + + // ── Self-referential and edge cases ──────────────────────────────── + + describe('edge cases', () => { + it('should ignore self-referential depends_on', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Self Ref', + tasks: [ + { title: 'Task 0', depends_on: [0] }, // self-reference + ], + }); + + const status = await town.getConvoyStatus(result.convoy.id); + // Self-references should be ignored + expect(status!.dependency_edges).toEqual([]); + }); + + it('should ignore out-of-bounds depends_on indices', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'OOB Deps', + tasks: [{ title: 'Task 0', depends_on: [5, -1, 100] }], + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status!.dependency_edges).toEqual([]); + }); + + // Cycle detection is tested in unit tests (convoy-branches.test.ts) + // since DO throws corrupt vitest-pool-workers isolated storage. + }); + + // ── Merge Mode ───────────────────────────────────────────────────── + + describe('merge mode', () => { + it('should default to review-then-land when merge_mode is not specified', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Default Mode', + tasks: [{ title: 'Task 1' }], + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status?.merge_mode).toBe('review-then-land'); + }); + + it('should accept review-then-land merge mode', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Review Then Land', + tasks: [{ title: 'Task 1' }, { title: 'Task 2' }], + merge_mode: 'review-then-land', + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status?.merge_mode).toBe('review-then-land'); + expect(status?.feature_branch).toBeTruthy(); + }); + + it('should accept review-and-merge merge mode', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Review And Merge', + tasks: [{ title: 'Task 1' }, { title: 'Task 2' }], + merge_mode: 'review-and-merge', + }); + + const status = await town.getConvoyStatus(result.convoy.id); + expect(status?.merge_mode).toBe('review-and-merge'); + expect(status?.feature_branch).toBeTruthy(); + }); + + it('should include merge_mode in listConvoysDetailed', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Mode Test', + tasks: [{ title: 'Task 1' }], + merge_mode: 'review-and-merge', + }); + + const detailed = await town.listConvoysDetailed(); + expect(detailed).toHaveLength(1); + expect(detailed[0].merge_mode).toBe('review-and-merge'); + }); + + it('should include merge_mode in review queue metadata for convoy beads', async () => { + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + + const result = await town.slingConvoy({ + rigId: 'rig-1', + convoyTitle: 'Review Queue Mode Test', + tasks: [{ title: 'Task 1' }], + merge_mode: 'review-then-land', + }); + + const beadId = result.beads[0].bead.bead_id; + const agentId = result.beads[0].agent.id; + + // Simulate agent completing work + await town.agentDone(agentId, { + branch: 'gt/toast/test1234', + summary: 'Done with task', + }); + + // Verify the source bead was closed and a review entry was created + const bead = await town.getBeadAsync(beadId); + expect(bead?.status).toBe('closed'); + + // Check that the MR bead exists with convoy metadata + const allBeads = await town.listBeads({ type: 'merge_request' }); + const mrBead = allBeads.find(b => b.metadata?.source_bead_id === beadId); + expect(mrBead).toBeTruthy(); + expect(mrBead?.metadata?.convoy_id).toBe(result.convoy.id); + }); + }); +}); diff --git a/cloudflare-gastown/test/unit/convoy-branches.test.ts b/cloudflare-gastown/test/unit/convoy-branches.test.ts new file mode 100644 index 0000000000..fc5c3496ad --- /dev/null +++ b/cloudflare-gastown/test/unit/convoy-branches.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Pure reimplementation of the branch naming functions from container-dispatch.ts + * for unit testing. Kept in sync with the actual implementation. + */ +function branchForAgent(name: string, beadId?: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + const beadSuffix = beadId ? `/${beadId.slice(0, 8)}` : ''; + return `gt/${slug}${beadSuffix}`; +} + +function branchForConvoyAgent(convoyFeatureBranch: string, name: string, beadId: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + // Strip /head suffix to get the convoy prefix, then place the agent branch as a sibling + const convoyPrefix = convoyFeatureBranch.replace(/\/head$/, ''); + return `${convoyPrefix}/gt/${slug}/${beadId.slice(0, 8)}`; +} + +describe('branchForAgent', () => { + it('should generate gt// format', () => { + expect(branchForAgent('Toast', 'abc12345-6789')).toBe('gt/toast/abc12345'); + }); + + it('should handle names with special characters', () => { + expect(branchForAgent('My Agent!', 'def456')).toBe('gt/my-agent-/def456'); + }); + + it('should work without a bead id', () => { + expect(branchForAgent('mayor')).toBe('gt/mayor'); + }); +}); + +describe('branchForConvoyAgent', () => { + it('should place agent branch as sibling of /head, not child', () => { + expect(branchForConvoyAgent('convoy/add-auth/abc12345/head', 'Toast', 'bead5678-full-id')).toBe( + 'convoy/add-auth/abc12345/gt/toast/bead5678' + ); + }); + + it('should handle feature branches with multiple slashes', () => { + expect(branchForConvoyAgent('convoy/fix-bug-123/def45678/head', 'Maple', 'zzzzzzzz-9999')).toBe( + 'convoy/fix-bug-123/def45678/gt/maple/zzzzzzzz' + ); + }); + + it('should sanitize agent name', () => { + expect(branchForConvoyAgent('convoy/test/abc/head', 'Agent #1!', 'bead0000-1111')).toBe( + 'convoy/test/abc/gt/agent-1-/bead0000' + ); + }); +}); + +describe('convoy feature branch naming', () => { + it('should follow convention: convoy///head', () => { + // Test the naming convention used by slingConvoy + const title = 'Add User Authentication'; + const convoyId = 'a1b2c3d4-5678-90ab-cdef-123456789012'; + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40); + const featureBranch = `convoy/${slug}/${convoyId.slice(0, 8)}/head`; + + expect(featureBranch).toBe('convoy/add-user-authentication/a1b2c3d4/head'); + }); + + it('should truncate long convoy titles to 40 chars', () => { + const title = 'This is a very long convoy title that exceeds the maximum slug length we want'; + const convoyId = '12345678-abcd'; + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40); + const featureBranch = `convoy/${slug}/${convoyId.slice(0, 8)}/head`; + + expect(slug.length).toBeLessThanOrEqual(40); + expect(featureBranch).toMatch(/^convoy\/.{1,40}\/12345678\/head$/); + }); + + it('should not conflict with agent branches in git refs', () => { + // Git refs are file-based: a ref at path X blocks refs under X/. + // The convoy feature branch ends with /head (a leaf ref), and agent + // branches sit alongside it as siblings under the same convoy prefix: + // convoy///head ← feature branch (leaf) + // convoy///gt// ← agent branch (sibling) + const featureBranch = 'convoy/add-auth/a1b2c3d4/head'; + const agentBranch = branchForConvoyAgent(featureBranch, 'Toast', 'bead5678-full'); + + expect(agentBranch).toBe('convoy/add-auth/a1b2c3d4/gt/toast/bead5678'); + // Agent branch must NOT start with featureBranch + '/' — that would + // require /head to be a directory, conflicting with the /head ref file. + expect(agentBranch.startsWith(featureBranch + '/')).toBe(false); + // Both share the convoy prefix (without /head) + const convoyPrefix = featureBranch.replace(/\/head$/, ''); + expect(agentBranch.startsWith(convoyPrefix + '/')).toBe(true); + expect(featureBranch.startsWith(convoyPrefix + '/')).toBe(true); + }); +}); + +// ── Cycle detection ───────────────────────────────────────────────── + +/** + * Pure reimplementation of the cycle detection from Town.do.ts slingConvoy. + * Throws if the depends_on graph contains a cycle. + */ +function validateNoCycles(tasks: Array<{ depends_on?: number[] }>): void { + const adj = new Map(); + const inDegree = new Map(); + for (let i = 0; i < tasks.length; i++) { + adj.set(i, []); + inDegree.set(i, 0); + } + for (let i = 0; i < tasks.length; i++) { + for (const depIdx of tasks[i].depends_on ?? []) { + if (depIdx < 0 || depIdx >= tasks.length || depIdx === i) continue; + adj.get(depIdx)!.push(i); + inDegree.set(i, (inDegree.get(i) ?? 0) + 1); + } + } + const queue: number[] = []; + for (const [node, deg] of inDegree) { + if (deg === 0) queue.push(node); + } + let visited = 0; + while (queue.length > 0) { + const node = queue.shift()!; + visited++; + for (const neighbor of adj.get(node) ?? []) { + const newDeg = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + if (visited < tasks.length) { + throw new Error( + `Convoy dependency graph contains a cycle — ${tasks.length - visited} tasks are involved in circular dependencies` + ); + } +} + +describe('cycle detection', () => { + it('should accept a valid DAG', () => { + expect(() => + validateNoCycles([{ depends_on: [] }, { depends_on: [0] }, { depends_on: [0, 1] }]) + ).not.toThrow(); + }); + + it('should accept fully parallel tasks', () => { + expect(() => validateNoCycles([{}, {}, {}])).not.toThrow(); + }); + + it('should reject a simple 2-node cycle', () => { + expect(() => validateNoCycles([{ depends_on: [1] }, { depends_on: [0] }])).toThrow(/cycle/i); + }); + + it('should reject a 3-node cycle', () => { + expect(() => + validateNoCycles([{ depends_on: [2] }, { depends_on: [0] }, { depends_on: [1] }]) + ).toThrow(/cycle/i); + }); + + it('should reject a cycle in a larger graph', () => { + // 0 → 1 → 2 → 3 → 1 (cycle: 1→2→3→1) + expect(() => + validateNoCycles([ + {}, + { depends_on: [0] }, + { depends_on: [1] }, + { depends_on: [2] }, + { depends_on: [3] }, // no cycle here, but 1→2→3 form a partial cycle + ]) + ).not.toThrow(); // Actually this is NOT a cycle — 3 depends on 2, not 1→3→1 + + // Real cycle: 1→2, 2→3, 3→1 + expect(() => + validateNoCycles([{}, { depends_on: [3] }, { depends_on: [1] }, { depends_on: [2] }]) + ).toThrow(/cycle/i); + }); + + it('should report the number of tasks in the cycle', () => { + expect(() => + validateNoCycles([ + { depends_on: [1] }, + { depends_on: [0] }, + {}, // not in cycle + ]) + ).toThrow('2 tasks'); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 438a9361f8..3076ba4297 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,7 @@ export default defineConfig([ 'build/**', 'supabase/functions/**', 'src/types/opencode.gen.ts', + 'src/lib/gastown/types/**', ], }, { diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index 34ca2ac7b9..b7bdc4fe70 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/Button'; import { Skeleton } from '@/components/ui/skeleton'; import { CreateRigDialog } from '@/components/gastown/CreateRigDialog'; import { ActivityFeedView } from '@/components/gastown/ActivityFeed'; +import { ConvoyTimeline } from '@/components/gastown/ConvoyTimeline'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; import { SystemTopology } from '@/components/gastown/SystemTopology'; import { @@ -24,6 +25,8 @@ import { Shield, Eye, ChevronRight, + ChevronDown, + Layers, } from 'lucide-react'; import { toast } from 'sonner'; import { formatDistanceToNow } from 'date-fns'; @@ -80,6 +83,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) const router = useRouter(); const trpc = useGastownTRPC(); const [isCreateRigOpen, setIsCreateRigOpen] = useState(false); + const [convoysCollapsed, setConvoysCollapsed] = useState(false); const { open: openDrawer } = useDrawerStack(); const queryClient = useQueryClient(); @@ -89,9 +93,24 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) ...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 200 }), refetchInterval: 5_000, }); + const convoysQuery = useQuery({ + ...trpc.gastown.listConvoys.queryOptions({ townId }), + refetchInterval: 8_000, + }); + const closeConvoyMutation = useMutation( + trpc.gastown.closeConvoy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.listConvoys.queryKey({ townId }), + }); + }, + onError: err => toast.error(err.message), + }) + ); const rigs = rigsQuery.data ?? []; const events = townEventsQuery.data ?? []; + const convoys = convoysQuery.data ?? []; const activityData = useMemo(() => bucketEventsOverTime(events), [events]); @@ -199,7 +218,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) {/* Main content area — no scroll container; viewport scrolls */}
{/* Left column: activity feed */} -
+
{/* Stats strip */}
+ {/* Active Convoys */} + {convoys.length > 0 && ( +
+ +
+ { + if (rigId) openDrawer({ type: 'bead', beadId, rigId }); + }} + onCloseConvoy={convoyId => closeConvoyMutation.mutate({ townId, convoyId })} + /> +
+
+ )} + {/* Activity feed — clickable items */}
diff --git a/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx b/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx index bb9f8021bf..ab4587f43e 100644 --- a/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import { toast } from 'sonner'; @@ -8,9 +8,10 @@ import { Button } from '@/components/Button'; import { Skeleton } from '@/components/ui/skeleton'; import { BeadBoard } from '@/components/gastown/BeadBoard'; import { AgentCard } from '@/components/gastown/AgentCard'; +import { ConvoyTimeline } from '@/components/gastown/ConvoyTimeline'; import { SlingDialog } from '@/components/gastown/SlingDialog'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; -import { Plus, GitBranch, Hexagon, Bot } from 'lucide-react'; +import { Plus, GitBranch, Hexagon, Bot, Layers, ChevronRight, ChevronDown } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; type RigDetailPageClientProps = { @@ -21,6 +22,7 @@ type RigDetailPageClientProps = { export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps) { const trpc = useGastownTRPC(); const [isSlingOpen, setIsSlingOpen] = useState(false); + const [convoysCollapsed, setConvoysCollapsed] = useState(false); const { open: openDrawer } = useDrawerStack(); const queryClient = useQueryClient(); @@ -61,9 +63,32 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps) }) ); + const convoysQuery = useQuery({ + ...trpc.gastown.listConvoys.queryOptions({ townId }), + refetchInterval: 8_000, + }); + const closeConvoyMutation = useMutation( + trpc.gastown.closeConvoy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.listConvoys.queryKey({ townId }), + }); + }, + onError: err => toast.error(err.message), + }) + ); + const beads = beadsQuery.data ?? []; const agents = agentsQuery.data ?? []; + // Filter convoys to those with at least one bead in this rig + const rigBeadIds = useMemo(() => new Set(beads.map(b => b.bead_id)), [beads]); + const rigConvoys = useMemo( + () => + (convoysQuery.data ?? []).filter(convoy => convoy.beads.some(b => rigBeadIds.has(b.bead_id))), + [convoysQuery.data, rigBeadIds] + ); + const openBeads = beads.filter(b => b.status === 'open' && b.type !== 'agent').length; const inProgressBeads = beads.filter( b => b.status === 'in_progress' && b.type !== 'agent' @@ -107,6 +132,38 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps)
+ {/* Convoy progress (if any) */} + {rigConvoys.length > 0 && ( +
+ +
+ + openDrawer({ type: 'bead', beadId, rigId: beadRigId ?? rigId }) + } + onCloseConvoy={convoyId => closeConvoyMutation.mutate({ townId, convoyId })} + /> +
+
+ )} + {/* Main content: columns layout */}
{/* Column 1: Bead Board */} diff --git a/src/components/gastown/ConvoyTimeline.tsx b/src/components/gastown/ConvoyTimeline.tsx index c11c188a83..7371bb459c 100644 --- a/src/components/gastown/ConvoyTimeline.tsx +++ b/src/components/gastown/ConvoyTimeline.tsx @@ -1,17 +1,19 @@ 'use client'; -import { useMemo } from 'react'; +import { useState, useMemo } from 'react'; import type { GastownOutputs } from '@/lib/gastown/trpc'; -import { Hexagon, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react'; -import { motion } from 'motion/react'; +import { CheckCircle, GitBranch, Loader2, X, ArrowRight } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; -type Bead = GastownOutputs['gastown']['listBeads'][number]; +type ConvoyDetail = GastownOutputs['gastown']['listConvoys'][number]; +type ConvoyBead = ConvoyDetail['beads'][number]; +type DependencyEdge = ConvoyDetail['dependency_edges'][number]; -type ConvoyTimelineProps = { - /** All beads from a rig (or across rigs) */ - beads: Bead[]; - agentNameById: Record; - onSelectBead?: (beadId: string) => void; +export type ConvoyTimelineProps = { + convoys: ConvoyDetail[]; + collapsed?: boolean; + onSelectBead?: (beadId: string, rigId: string | null) => void; + onCloseConvoy?: (convoyId: string) => void; }; const STATUS_COLORS: Record = { @@ -29,170 +31,264 @@ const STATUS_DOT_COLORS: Record = { }; /** - * Horizontal timeline showing bead completion events over time. - * Groups beads by parent_bead_id to form "convoys". + * Compute topological waves from beads and dependency edges using Kahn's algorithm. + * Wave 0 = beads with no incoming 'blocks' edges (can run immediately). + * Wave N = beads whose all blockers are in waves < N. + * Returns beads grouped by wave index, preserving creation order within each wave. */ -export function ConvoyTimeline({ beads, agentNameById, onSelectBead }: ConvoyTimelineProps) { - // Group into convoys by parent_bead_id - const convoys = useMemo(() => { - const groups: Record = {}; - const standalone: Bead[] = []; - - for (const bead of beads) { - if (bead.parent_bead_id) { - const key = bead.parent_bead_id; - groups[key] ??= []; - groups[key].push(bead); - } else { - standalone.push(bead); +function computeWaves(beadList: ConvoyBead[], edges: DependencyEdge[]): ConvoyBead[][] { + const beadIds = new Set(beadList.map(b => b.bead_id)); + + // Build in-degree map and adjacency list (only for beads in this convoy) + const inDegree = new Map(); + const blockedBy = new Map(); // bead_id → list of blockers + for (const b of beadList) { + inDegree.set(b.bead_id, 0); + blockedBy.set(b.bead_id, []); + } + for (const edge of edges) { + if (!beadIds.has(edge.bead_id) || !beadIds.has(edge.depends_on_bead_id)) continue; + inDegree.set(edge.bead_id, (inDegree.get(edge.bead_id) ?? 0) + 1); + blockedBy.get(edge.bead_id)?.push(edge.depends_on_bead_id); + } + + const beadById = new Map(beadList.map(b => [b.bead_id, b])); + const waves: ConvoyBead[][] = []; + const remaining = new Set(beadIds); + + while (remaining.size > 0) { + // Collect all beads with in-degree 0 + const wave: ConvoyBead[] = []; + for (const id of remaining) { + if ((inDegree.get(id) ?? 0) === 0) { + const bead = beadById.get(id); + if (bead) wave.push(bead); } } - const result: Array<{ id: string; label: string; beads: Bead[]; isConvoy: boolean }> = []; - - // Add actual convoys - for (const [parentId, children] of Object.entries(groups)) { - const parent = beads.find(b => b.bead_id === parentId); - result.push({ - id: parentId, - label: parent?.title ?? `Convoy ${parentId.slice(0, 8)}`, - beads: children.sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() - ), - isConvoy: true, - }); + // If no zero-degree nodes remain, there's a cycle — dump remaining as final wave + if (wave.length === 0) { + const rest: ConvoyBead[] = []; + for (const id of remaining) { + const bead = beadById.get(id); + if (bead) rest.push(bead); + } + waves.push(rest); + break; } - // Add standalone beads as single-bead "convoys" - for (const bead of standalone) { - if (!groups[bead.bead_id]) { - result.push({ - id: bead.bead_id, - label: bead.title, - beads: [bead], - isConvoy: false, - }); + waves.push(wave); + + // Remove wave nodes and decrement in-degrees + for (const bead of wave) { + remaining.delete(bead.bead_id); + } + for (const id of remaining) { + const blockers = blockedBy.get(id) ?? []; + let newDegree = 0; + for (const dep of blockers) { + if (remaining.has(dep)) newDegree++; } + inDegree.set(id, newDegree); } + } - return result; - }, [beads]); + return waves; +} - if (beads.length === 0) { - return ( -
- -

No beads to visualize.

-
- ); +/** + * Renders convoy progress as horizontal timeline tracks with DAG visualization. + * Beads are grouped into waves based on dependency edges. + * Collapse state is managed by the parent via the `collapsed` prop. + */ +export function ConvoyTimeline({ + convoys, + collapsed = false, + onSelectBead, + onCloseConvoy, +}: ConvoyTimelineProps) { + if (convoys.length === 0) { + return null; } return ( -
- {convoys.map(convoy => { - const completedCount = convoy.beads.filter(b => b.status === 'closed').length; - const total = convoy.beads.length; - const hasStalled = convoy.beads.some(b => b.status === 'open' && !b.assignee_agent_bead_id); - - return ( -
- {/* Convoy header */} -
-
- {convoy.isConvoy && ( - - CONVOY - - )} - - {convoy.label} + + {!collapsed && ( + + {convoys.map(convoy => ( + onCloseConvoy(convoy.id) : undefined} + /> + ))} + + )} + + ); +} + +function ConvoyCard({ + convoy, + onSelectBead, + onClose, +}: { + convoy: ConvoyDetail; + onSelectBead?: (beadId: string, rigId: string | null) => void; + onClose?: () => void; +}) { + const [confirming, setConfirming] = useState(false); + const progress = convoy.total_beads > 0 ? convoy.closed_beads / convoy.total_beads : 0; + + const waves = useMemo( + () => computeWaves(convoy.beads, convoy.dependency_edges ?? []), + [convoy.beads, convoy.dependency_edges] + ); + + const hasDag = (convoy.dependency_edges ?? []).length > 0; + + return ( +
+ {/* Convoy header */} +
+
+ + CONVOY + + + {convoy.title} + + {convoy.feature_branch && ( + + + {convoy.feature_branch} + + )} +
+
+ + {convoy.closed_beads}/{convoy.total_beads} + + {onClose && ( + <> + {confirming ? ( + + + -
-
- {hasStalled && ( - - - Stranded - + ) : ( + + )} + + )} +
+
+ + {/* Progress bar */} +
+ +
+ + {/* DAG wave layout or flat list */} + {hasDag ? ( +
+ {waves.map((wave, waveIdx) => ( +
+ {waveIdx > 0 && } +
+ {wave.length > 1 && ( + wave {waveIdx + 1} )} - - {completedCount}/{total} - +
+ {wave.map((bead, i) => ( + + ))} +
- - {/* Timeline track */} -
- {/* Track line */} -
- - {convoy.beads.map((bead, i) => { - const assigneeName = bead.assignee_agent_bead_id - ? agentNameById[bead.assignee_agent_bead_id] - : null; - - return ( - onSelectBead?.(bead.bead_id)} - className={`relative z-10 flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[10px] transition-all hover:scale-105 ${STATUS_COLORS[bead.status] ?? 'border-white/10 bg-white/[0.03]'}`} - title={`${bead.title} (${bead.status})`} - > - {bead.status === 'closed' ? ( - - ) : bead.status === 'in_progress' ? ( - - ) : ( - - )} - - {bead.title.slice(0, 20)} - - {assigneeName && {assigneeName}} - - ); - })} -
-
- ); - })} + ))} +
+ ) : ( +
+
+ {convoy.beads.map((bead, i) => ( + + ))} +
+ )}
); } -/** - * Detects convoys where beads are open but no agents are assigned. - */ -export function StrandedConvoyAlert({ beads, onSling }: { beads: Bead[]; onSling?: () => void }) { - const strandedBeads = beads.filter(b => b.status === 'open' && !b.assignee_agent_bead_id); - - if (strandedBeads.length === 0) return null; - +function BeadChip({ + bead, + delay, + onSelect, +}: { + bead: ConvoyBead; + delay: number; + onSelect?: (beadId: string, rigId: string | null) => void; +}) { return ( -
- -
- - {strandedBeads.length} stranded bead{strandedBeads.length > 1 ? 's' : ''} - - — open but no agent assigned -
- {onSling && ( - + onSelect?.(bead.bead_id, bead.rig_id)} + className={`relative z-10 flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-all hover:scale-105 ${STATUS_COLORS[bead.status] ?? 'border-white/10 bg-white/[0.03]'}`} + title={`${bead.title} (${bead.status})${bead.assignee_agent_name ? ` — ${bead.assignee_agent_name}` : ''}`} + > + {bead.status === 'closed' ? ( + + ) : bead.status === 'in_progress' ? ( + + ) : ( + )} -
+ {bead.title} + {bead.assignee_agent_name && ( + {bead.assignee_agent_name} + )} + ); } diff --git a/src/components/gastown/drawer-panels/BeadPanel.tsx b/src/components/gastown/drawer-panels/BeadPanel.tsx index f88a5fd378..3b86c6a548 100644 --- a/src/components/gastown/drawer-panels/BeadPanel.tsx +++ b/src/components/gastown/drawer-panels/BeadPanel.tsx @@ -21,8 +21,10 @@ import { Bot, Network, ArrowDownRight, + ArrowUpRight, GitPullRequest, CircleDot, + Layers, } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -55,6 +57,13 @@ export function BeadPanel({ const agentsQuery = useQuery(trpc.gastown.listAgents.queryOptions({ rigId })); const rigQuery = useQuery(trpc.gastown.getRig.queryOptions({ rigId })); + // Fetch convoy data for DAG edges — townId is needed for the query + const townId = rigQuery.data?.town_id; + const convoysQuery = useQuery({ + ...trpc.gastown.listConvoys.queryOptions({ townId: townId ?? '' }), + enabled: !!townId, + }); + const bead = (beadsQuery.data ?? []).find(b => b.bead_id === beadId); const agentNameById = (agentsQuery.data ?? []).reduce>((acc, a) => { acc[a.id] = a.name; @@ -69,15 +78,21 @@ export function BeadPanel({ ? agentNameById[bead.assignee_agent_bead_id] : null; - const townId = rigQuery.data?.town_id; - // Extract PR URL from bead metadata (set by setReviewPrUrl for merge_request beads). // Only allow https:// URLs to prevent XSS via javascript: protocol injection. const prUrl = extractPrUrl(bead.metadata); - // Build related beads from the flat list (no extra API needed) + // Build related beads from the flat list and convoy DAG data const allBeads = beadsQuery.data ?? []; - const relatedBeads = buildRelatedBeads(bead, allBeads); + const convoys = convoysQuery.data ?? []; + const relatedBeads = buildRelatedBeads(bead, allBeads, convoys); + + // Find parent convoy for metadata display + const beadConvoyId = + typeof bead.metadata?.convoy_id === 'string' ? bead.metadata.convoy_id : null; + const parentConvoy = convoys.find( + c => c.id === beadConvoyId || c.beads.some(b => b.bead_id === bead.bead_id) + ); return (
@@ -169,6 +184,25 @@ export function BeadPanel({ )}
+ {/* Convoy membership */} + {parentConvoy && ( +
+
+ + Convoy: + {parentConvoy.title} +
+ {parentConvoy.feature_branch && ( +
+ + + {parentConvoy.feature_branch} + +
+ )} +
+ )} + {/* PR link for merge_request beads */} {bead.type === 'merge_request' && prUrl && (
@@ -287,6 +321,7 @@ type BeadLike = { status: string; title: string; parent_bead_id: string | null; + rig_id?: string | null; metadata: Record; }; @@ -297,12 +332,99 @@ type RelatedBead = { bead: BeadLike; }; -/** Compute the DAG neighborhood of a bead from the flat list. */ -function buildRelatedBeads(bead: BeadLike, allBeads: BeadLike[]): RelatedBead[] { +type ConvoyLike = { + id: string; + title: string; + feature_branch?: string | null; + beads: Array<{ bead_id: string; title: string; status: string; rig_id: string | null }>; + dependency_edges?: Array<{ bead_id: string; depends_on_bead_id: string }>; +}; + +/** + * Compute the DAG neighborhood of a bead from the flat list and convoy data. + * Includes: children, source/review links, blockers, and dependents from convoy DAG. + */ +function buildRelatedBeads( + bead: BeadLike, + allBeads: BeadLike[], + convoys: ConvoyLike[] +): RelatedBead[] { const related: RelatedBead[] = []; - // Parent bead (already shown in metadata grid, but include in DAG for completeness) - // Skip — the metadata grid already renders a clickable parent link. + // Find which convoy this bead belongs to (if any) via metadata or convoy beads list + const convoyId = typeof bead.metadata?.convoy_id === 'string' ? bead.metadata.convoy_id : null; + const parentConvoy = convoys.find( + c => c.id === convoyId || c.beads.some(b => b.bead_id === bead.bead_id) + ); + + // Show convoy membership + if (parentConvoy) { + // Find blockers (beads that this bead depends on / is blocked by) + const edges = parentConvoy.dependency_edges ?? []; + const blockerIds = new Set( + edges.filter(e => e.bead_id === bead.bead_id).map(e => e.depends_on_bead_id) + ); + for (const blockerId of blockerIds) { + const blockerBead = allBeads.find(b => b.bead_id === blockerId); + const convoyBead = parentConvoy.beads.find(b => b.bead_id === blockerId); + if (blockerBead) { + related.push({ + relation: 'blocker', + label: 'Blocked by', + icon: ArrowUpRight, + bead: blockerBead, + }); + } else if (convoyBead) { + // Bead is in convoy but not in the rig's bead list — use convoy data + related.push({ + relation: 'blocker', + label: 'Blocked by', + icon: ArrowUpRight, + bead: { + bead_id: convoyBead.bead_id, + type: 'issue', + status: convoyBead.status, + title: convoyBead.title, + parent_bead_id: null, + rig_id: convoyBead.rig_id, + metadata: {}, + }, + }); + } + } + + // Find dependents (beads that depend on / are blocked by this bead) + const dependentIds = new Set( + edges.filter(e => e.depends_on_bead_id === bead.bead_id).map(e => e.bead_id) + ); + for (const depId of dependentIds) { + const depBead = allBeads.find(b => b.bead_id === depId); + const convoyBead = parentConvoy.beads.find(b => b.bead_id === depId); + if (depBead) { + related.push({ + relation: 'dependent', + label: 'Blocks', + icon: ArrowDownRight, + bead: depBead, + }); + } else if (convoyBead) { + related.push({ + relation: 'dependent', + label: 'Blocks', + icon: ArrowDownRight, + bead: { + bead_id: convoyBead.bead_id, + type: 'issue', + status: convoyBead.status, + title: convoyBead.title, + parent_bead_id: null, + rig_id: convoyBead.rig_id, + metadata: {}, + }, + }); + } + } + } // Child beads (beads whose parent_bead_id = this bead) for (const b of allBeads) { @@ -328,7 +450,5 @@ function buildRelatedBeads(bead: BeadLike, allBeads: BeadLike[]): RelatedBead[] } } - // Beads that share the same parent (siblings) — skip, too noisy for now - return related; } diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 70ffd0e369..3e8c6974c0 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ import type { TRPCContext } from './init'; export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< { @@ -57,8 +56,8 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< townId: string; name: string; gitUrl: string; - defaultBranch?: string; - platformIntegrationId?: string; + defaultBranch?: string | undefined; + platformIntegrationId?: string | undefined; }; output: { id: string; @@ -66,7 +65,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; }; @@ -82,7 +81,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; }[]; @@ -98,7 +97,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; agents: { @@ -151,7 +150,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - status?: 'closed' | 'failed' | 'in_progress' | 'open'; + status?: 'closed' | 'failed' | 'in_progress' | 'open' | undefined; }; output: { bead_id: string; @@ -218,8 +217,8 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< input: { rigId: string; title: string; - body?: string; - model?: string; + body?: string | undefined; + model?: string | undefined; }; output: { bead: { @@ -266,8 +265,8 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< input: { townId: string; message: string; - model?: string; - rigId?: string; + model?: string | undefined; + rigId?: string | undefined; }; output: { agentId: string; @@ -281,13 +280,13 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }; output: { configured: boolean; - townId?: string; - session?: { + townId: string | null; + session: { agentId: string; sessionId: string; status: 'active' | 'idle' | 'starting'; lastActivityAt: string; - }; + } | null; }; meta: object; }>; @@ -344,68 +343,106 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< output: { env_vars: Record; git_auth: { - github_token?: string; - gitlab_token?: string; - gitlab_instance_url?: string; - platform_integration_id?: string; + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; }; - owner_user_id?: string; - kilocode_token?: string; - default_model?: string; - small_model?: string; - max_polecats_per_rig?: number; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; merge_strategy: 'direct' | 'pr'; - refinery?: { - gates: string[]; - auto_merge: boolean; - require_clean_merge: boolean; - }; - alarm_interval_active?: number; - alarm_interval_idle?: number; - container?: { - sleep_after_minutes?: number; - }; + refinery?: + | { + gates: string[]; + auto_merge: boolean; + require_clean_merge: boolean; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; }; meta: object; }>; updateTownConfig: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; - config: Record; + config: { + env_vars?: Record | undefined; + git_auth?: + | { + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; + } + | undefined; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; + merge_strategy?: 'direct' | 'pr' | undefined; + refinery?: + | { + gates?: string[] | undefined; + auto_merge?: boolean | undefined; + require_clean_merge?: boolean | undefined; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; + }; }; output: { env_vars: Record; git_auth: { - github_token?: string; - gitlab_token?: string; - gitlab_instance_url?: string; - platform_integration_id?: string; + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; }; - owner_user_id?: string; - kilocode_token?: string; - default_model?: string; - small_model?: string; - max_polecats_per_rig?: number; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; merge_strategy: 'direct' | 'pr'; - refinery?: { - gates: string[]; - auto_merge: boolean; - require_clean_merge: boolean; - }; - alarm_interval_active?: number; - alarm_interval_idle?: number; - container?: { - sleep_after_minutes?: number; - }; + refinery?: + | { + gates: string[]; + auto_merge: boolean; + require_clean_merge: boolean; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; }; meta: object; }>; getBeadEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - beadId?: string; - since?: string; - limit?: number; + beadId?: string | undefined; + since?: string | undefined; + limit?: number | undefined; }; output: { bead_event_id: string; @@ -416,16 +453,16 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< new_value: string | null; metadata: Record; created_at: string; - rig_id: string | null; - rig_name?: string; + rig_id?: string | undefined; + rig_name?: string | undefined; }[]; meta: object; }>; getTownEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; - since?: string; - limit?: number; + since?: string | undefined; + limit?: number | undefined; }; output: { bead_event_id: string; @@ -436,11 +473,70 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< new_value: string | null; metadata: Record; created_at: string; - rig_id: string | null; - rig_name?: string; + rig_id?: string | undefined; + rig_name?: string | undefined; }[]; meta: object; }>; + listConvoys: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + }[]; + meta: object; + }>; + closeConvoy: import('@trpc/server').TRPCMutationProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; }> >; export type GastownRouter = typeof gastownRouter; @@ -515,8 +611,8 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute townId: string; name: string; gitUrl: string; - defaultBranch?: string; - platformIntegrationId?: string; + defaultBranch?: string | undefined; + platformIntegrationId?: string | undefined; }; output: { id: string; @@ -524,7 +620,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; }; @@ -540,7 +636,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; }[]; @@ -556,7 +652,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute name: string; git_url: string; default_branch: string; - platform_integration_id: string; + platform_integration_id: string | null; created_at: string; updated_at: string; agents: { @@ -609,7 +705,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - status?: 'closed' | 'failed' | 'in_progress' | 'open'; + status?: 'closed' | 'failed' | 'in_progress' | 'open' | undefined; }; output: { bead_id: string; @@ -676,8 +772,8 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute input: { rigId: string; title: string; - body?: string; - model?: string; + body?: string | undefined; + model?: string | undefined; }; output: { bead: { @@ -724,8 +820,8 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute input: { townId: string; message: string; - model?: string; - rigId?: string; + model?: string | undefined; + rigId?: string | undefined; }; output: { agentId: string; @@ -739,13 +835,13 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }; output: { configured: boolean; - townId?: string; - session?: { + townId: string | null; + session: { agentId: string; sessionId: string; status: 'active' | 'idle' | 'starting'; lastActivityAt: string; - }; + } | null; }; meta: object; }>; @@ -802,68 +898,106 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute output: { env_vars: Record; git_auth: { - github_token?: string; - gitlab_token?: string; - gitlab_instance_url?: string; - platform_integration_id?: string; + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; }; - owner_user_id?: string; - kilocode_token?: string; - default_model?: string; - small_model?: string; - max_polecats_per_rig?: number; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; merge_strategy: 'direct' | 'pr'; - refinery?: { - gates: string[]; - auto_merge: boolean; - require_clean_merge: boolean; - }; - alarm_interval_active?: number; - alarm_interval_idle?: number; - container?: { - sleep_after_minutes?: number; - }; + refinery?: + | { + gates: string[]; + auto_merge: boolean; + require_clean_merge: boolean; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; }; meta: object; }>; updateTownConfig: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; - config: Record; + config: { + env_vars?: Record | undefined; + git_auth?: + | { + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; + } + | undefined; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; + merge_strategy?: 'direct' | 'pr' | undefined; + refinery?: + | { + gates?: string[] | undefined; + auto_merge?: boolean | undefined; + require_clean_merge?: boolean | undefined; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; + }; }; output: { env_vars: Record; git_auth: { - github_token?: string; - gitlab_token?: string; - gitlab_instance_url?: string; - platform_integration_id?: string; + github_token?: string | undefined; + gitlab_token?: string | undefined; + gitlab_instance_url?: string | undefined; + platform_integration_id?: string | undefined; }; - owner_user_id?: string; - kilocode_token?: string; - default_model?: string; - small_model?: string; - max_polecats_per_rig?: number; + owner_user_id?: string | undefined; + kilocode_token?: string | undefined; + default_model?: string | undefined; + small_model?: string | undefined; + max_polecats_per_rig?: number | undefined; merge_strategy: 'direct' | 'pr'; - refinery?: { - gates: string[]; - auto_merge: boolean; - require_clean_merge: boolean; - }; - alarm_interval_active?: number; - alarm_interval_idle?: number; - container?: { - sleep_after_minutes?: number; - }; + refinery?: + | { + gates: string[]; + auto_merge: boolean; + require_clean_merge: boolean; + } + | undefined; + alarm_interval_active?: number | undefined; + alarm_interval_idle?: number | undefined; + container?: + | { + sleep_after_minutes?: number | undefined; + } + | undefined; }; meta: object; }>; getBeadEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - beadId?: string; - since?: string; - limit?: number; + beadId?: string | undefined; + since?: string | undefined; + limit?: number | undefined; }; output: { bead_event_id: string; @@ -874,16 +1008,16 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute new_value: string | null; metadata: Record; created_at: string; - rig_id: string | null; - rig_name?: string; + rig_id?: string | undefined; + rig_name?: string | undefined; }[]; meta: object; }>; getTownEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; - since?: string; - limit?: number; + since?: string | undefined; + limit?: number | undefined; }; output: { bead_event_id: string; @@ -894,11 +1028,70 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute new_value: string | null; metadata: Record; created_at: string; - rig_id: string | null; - rig_name?: string; + rig_id?: string | undefined; + rig_name?: string | undefined; }[]; meta: object; }>; + listConvoys: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + }[]; + meta: object; + }>; + closeConvoy: import('@trpc/server').TRPCMutationProcedure<{ + input: { + townId: string; + convoyId: string; + }; + output: { + id: string; + title: string; + status: 'active' | 'landed'; + total_beads: number; + closed_beads: number; + created_by: string | null; + created_at: string; + landed_at: string | null; + feature_branch: string | null; + merge_mode: string | null; + beads: { + bead_id: string; + title: string; + status: string; + rig_id: string | null; + assignee_agent_name: string | null; + }[]; + dependency_edges: { + bead_id: string; + depends_on_bead_id: string; + }[]; + } | null; + meta: object; + }>; }> >; }> diff --git a/src/lib/gastown/types/schemas.d.ts b/src/lib/gastown/types/schemas.d.ts index 9a91ebadd5..7fe53c7c0b 100644 --- a/src/lib/gastown/types/schemas.d.ts +++ b/src/lib/gastown/types/schemas.d.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import { z } from 'zod'; export declare const TownOutput: z.ZodObject< { id: z.ZodString; @@ -153,6 +153,63 @@ export declare const PtySessionOutput: z.ZodObject< }, z.core.$strip >; +export declare const ConvoyOutput: z.ZodObject< + { + id: z.ZodString; + title: z.ZodString; + status: z.ZodEnum<{ + active: 'active'; + landed: 'landed'; + }>; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + created_by: z.ZodNullable; + created_at: z.ZodString; + landed_at: z.ZodNullable; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip +>; +export declare const ConvoyDetailOutput: z.ZodObject< + { + id: z.ZodString; + title: z.ZodString; + status: z.ZodEnum<{ + active: 'active'; + landed: 'landed'; + }>; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + created_by: z.ZodNullable; + created_at: z.ZodString; + landed_at: z.ZodNullable; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + beads: z.ZodArray< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + rig_id: z.ZodNullable; + assignee_agent_name: z.ZodNullable; + }, + z.core.$strip + > + >; + dependency_edges: z.ZodArray< + z.ZodObject< + { + bead_id: z.ZodString; + depends_on_bead_id: z.ZodString; + }, + z.core.$strip + > + >; + }, + z.core.$strip +>; export declare const SlingResultOutput: z.ZodObject< { bead: z.ZodObject< @@ -484,6 +541,69 @@ export declare const RpcPtySessionOutput: z.ZodPipe< z.core.$strip > >; +export declare const RpcConvoyOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + id: z.ZodString; + title: z.ZodString; + status: z.ZodEnum<{ + active: 'active'; + landed: 'landed'; + }>; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + created_by: z.ZodNullable; + created_at: z.ZodString; + landed_at: z.ZodNullable; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > +>; +export declare const RpcConvoyDetailOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + id: z.ZodString; + title: z.ZodString; + status: z.ZodEnum<{ + active: 'active'; + landed: 'landed'; + }>; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + created_by: z.ZodNullable; + created_at: z.ZodString; + landed_at: z.ZodNullable; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + beads: z.ZodArray< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + rig_id: z.ZodNullable; + assignee_agent_name: z.ZodNullable; + }, + z.core.$strip + > + >; + dependency_edges: z.ZodArray< + z.ZodObject< + { + bead_id: z.ZodString; + depends_on_bead_id: z.ZodString; + }, + z.core.$strip + > + >; + }, + z.core.$strip + > +>; export declare const RpcSlingResultOutput: z.ZodPipe< z.ZodAny, z.ZodObject<