Skip to content
Merged
9 changes: 9 additions & 0 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
BeadType,
Convoy,
ConvoyDetail,
ConvoyStartResult,
GastownEnv,
Mail,
MayorGastownEnv,
Expand Down Expand Up @@ -334,13 +335,21 @@ export class MayorGastownClient {
tasks: Array<{ title: string; body?: string; depends_on?: number[] }>;
merge_mode?: 'review-then-land' | 'review-and-merge';
parallel?: boolean;
staged?: boolean;
}): Promise<SlingBatchResult> {
return this.request<SlingBatchResult>(this.mayorPath('/sling-batch'), {
method: 'POST',
body: JSON.stringify(input),
});
}

async startConvoy(convoyId: string): Promise<ConvoyStartResult> {
return this.request<ConvoyStartResult>(this.mayorPath(`/convoys/${convoyId}/start`), {
method: 'POST',
body: JSON.stringify({}),
});
}

async listConvoys(): Promise<Convoy[]> {
return this.request<Convoy[]>(this.mayorPath('/convoys'));
}
Expand Down
70 changes: 70 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
Bead,
Convoy,
ConvoyDetail,
ConvoyStartResult,
Rig,
SlingBatchResult,
SlingResult,
Expand Down Expand Up @@ -60,13 +61,26 @@ const FAKE_CONVOY: Convoy = {
id: 'convoy-1',
title: 'JWT Authentication',
status: 'active',
staged: false,
total_beads: 3,
closed_beads: 1,
created_by: null,
created_at: '2026-03-05T00:00:00Z',
landed_at: null,
};

const FAKE_STAGED_CONVOY: Convoy = {
id: 'convoy-staged-1',
title: 'Big Refactor',
status: 'active',
staged: true,
total_beads: 2,
closed_beads: 0,
created_by: null,
created_at: '2026-03-05T00:00:00Z',
landed_at: null,
};

function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): MayorGastownClient {
return {
sling: vi.fn<() => Promise<SlingResult>>().mockResolvedValue({
Expand Down Expand Up @@ -95,8 +109,22 @@ function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): Mayor
],
}),
listConvoys: vi.fn<() => Promise<Convoy[]>>().mockResolvedValue([FAKE_CONVOY]),
startConvoy: vi.fn<() => Promise<ConvoyStartResult>>().mockResolvedValue({
convoy: { ...FAKE_STAGED_CONVOY, status: 'active', staged: false },
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' },
},
],
}),
getConvoyStatus: vi.fn<() => Promise<ConvoyDetail>>().mockResolvedValue({
...FAKE_CONVOY,
staged: false,
beads: [
{
bead_id: 'bead-1',
Expand Down Expand Up @@ -339,4 +367,46 @@ describe('mayor tools', () => {
expect(client.acknowledgeEscalation).toHaveBeenCalledWith('esc-1');
});
});

describe('gt_sling_batch staged', () => {
it('passes staged=true to client and reports convoy as staged', async () => {
client = makeFakeMayorClient({
slingBatch: vi.fn<() => Promise<SlingBatchResult>>().mockResolvedValue({
convoy: FAKE_STAGED_CONVOY,
beads: [
{
bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' },
agent: null,
},
{
bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' },
agent: null,
},
],
}),
});
tools = createMayorTools(client);

const tasks = [{ title: 'Task 1' }, { title: 'Task 2', depends_on: [0] }];
const result = await tools.gt_sling_batch.execute(
{ rig_id: 'rig-1', convoy_title: 'Big Refactor', tasks, staged: true },
CTX
);

expect(result).toContain('Convoy staged:');
expect(result).toContain('convoy-staged-1');
expect(result).toContain('gt_convoy_start');
expect(result).toContain('unassigned, pending gt_convoy_start');
expect(client.slingBatch).toHaveBeenCalledWith(expect.objectContaining({ staged: true }));
});
});

describe('gt_convoy_start', () => {
it('starts a staged convoy and reports bead count', async () => {
const result = await tools.gt_convoy_start.execute({ convoy_id: 'convoy-staged-1' }, CTX);
expect(result).toContain('Convoy started');
expect(result).toContain('2 bead(s)');
expect(client.startConvoy).toHaveBeenCalledWith('convoy-staged-1');
});
});
});
44 changes: 38 additions & 6 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export function createMayorTools(client: MayorGastownClient) {
'that need ordering, which causes merge conflicts and failures.'
)
.optional(),
staged: tool.schema
.boolean()
.describe(
'If true, creates the convoy plan without dispatching agents. ' +
'The user can review and edit before calling gt_convoy_start to begin execution. ' +
'Default: false (dispatch immediately).'
)
.optional(),
},
async execute(args) {
const result = await client.slingBatch({
Expand All @@ -177,21 +185,30 @@ export function createMayorTools(client: MayorGastownClient) {
tasks: args.tasks,
merge_mode: args.merge_mode,
parallel: args.parallel,
staged: args.staged,
});

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})`
(
b: { bead: { title: string }; agent: { name: string; id: string } | null },
i: number
) =>
b.agent
? ` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})`
: ` ${i + 1}. "${b.bead.title}" (unassigned, pending gt_convoy_start)`
);
const mode = args.merge_mode ?? 'review-then-land';
const staged = result.convoy.staged;
return [
`Convoy created: "${result.convoy.title}" (${result.convoy.id})`,
`Convoy ${staged ? 'staged' : '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.`,
staged
? `Convoy is staged — agents have NOT been dispatched. Call gt_convoy_start with convoy_id "${result.convoy.id}" when ready to begin execution.`
: 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');
},
}),
Expand Down Expand Up @@ -223,6 +240,21 @@ export function createMayorTools(client: MayorGastownClient) {
},
}),

gt_convoy_start: tool({
description:
'Start a staged convoy. Transitions the convoy from staged (planned but not executing) ' +
'to active: hooks agents to all tracked beads and begins dispatch. ' +
'Call this when the user approves a staged plan and says to start it.',
args: {
convoy_id: tool.schema.string().describe('The UUID of the staged convoy to start'),
},
async execute(args) {
const result = await client.startConvoy(args.convoy_id);
const beadCount = result.beads?.length ?? 0;
return `Convoy started. ${beadCount} bead(s) dispatched to agents.`;
},
}),

gt_mail_send: tool({
description:
'Send a mail message to an agent in any rig. ' +
Expand Down
12 changes: 11 additions & 1 deletion cloudflare-gastown/container/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,23 +88,33 @@ export type SlingResult = {
};

// Sling batch result (convoy + beads + agents)
// agent is null for staged convoys (agents aren't assigned until gt_convoy_start)
export type SlingBatchResult = {
convoy: Convoy;
beads: Array<{ bead: Bead; agent: Agent }>;
beads: Array<{ bead: Bead; agent: Agent | null }>;
};

// Convoy summary (returned by list and status endpoints)
// Staging is tracked by the `staged` boolean, not the status field.
// status tracks the convoy lifecycle: active (in progress) or landed (complete).
export type Convoy = {
id: string;
title: string;
status: 'active' | 'landed';
staged: boolean;
total_beads: number;
closed_beads: number;
created_by: string | null;
created_at: string;
landed_at: string | null;
};

// Result returned by POST /convoys/:id/start
export type ConvoyStartResult = {
convoy: Convoy;
beads: Array<{ bead: Bead; agent: Agent }>;
};

// Detailed convoy status with per-bead breakdown
export type ConvoyDetail = Convoy & {
beads: Array<{
Expand Down
8 changes: 6 additions & 2 deletions cloudflare-gastown/src/db/tables/convoy-metadata.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const ConvoyMetadataRecord = z.object({
* individually, like standalone beads.
*/
merge_mode: ConvoyMergeMode.nullable(),
/** 1 = staged (planned, agents not dispatched), 0 = active (SQLite boolean) */
staged: z.number().int().default(0),
});

export type ConvoyMetadataRecord = z.output<typeof ConvoyMetadataRecord>;
Expand All @@ -32,14 +34,16 @@ export function createTableConvoyMetadata(): string {
closed_beads: `integer not null default 0`,
landed_at: `text`,
feature_branch: `text`,
merge_mode: `text`,
merge_mode: `text check(merge_mode in ('review-then-land', 'review-and-merge'))`,
staged: `integer not null default 0`,
});
}

/** 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`,
`ALTER TABLE convoy_metadata ADD COLUMN merge_mode text check(merge_mode in ('review-then-land', 'review-and-merge'))`,
`ALTER TABLE convoy_metadata ADD COLUMN staged integer not null default 0`,
];
}
Loading
Loading