Skip to content

Commit 60bc2bb

Browse files
authored
feat(gastown): add staged convoys for plan-review-iterate workflow (#1077)
* feat(gastown): add staged convoys for plan-review-iterate workflow Adds staged mode to convoys so users can plan work before executing. gt_sling_batch gains a staged param that creates convoy + beads without hooking agents. New gt_convoy_start tool transitions staged convoys to open, triggering agent dispatch. Dashboard shows staged convoys with draft visual state and a Start button. Mayor prompt updated with staged convoy instructions. Closes #1006 * fix(gastown): resolve TS2589 deep type instantiation in handleMayorConvoyStart * fix(gastown): handle null agents in staged convoys and exclude staged convoys from deacon patrol SlingBatchResult.agent is null for staged convoys since agents aren't assigned until gt_convoy_start. The gt_sling_batch tool now handles this gracefully instead of crashing on b.agent.name. feedStrandedConvoys now joins convoy_metadata and filters on staged=0 so the deacon patrol doesn't auto-assign agents to staged convoy beads. * feat(gastown): add convoy drawer UI with staged indicator and start button - Add staged field to ConvoyOutput tRPC schema - Add startConvoy tRPC mutation - Add convoy ResourceRef type to DrawerStack - Create ConvoyPanel drawer with DAG wave layout, metadata, and start button - Update ConvoyTimeline with staged badge, clickable title to open drawer, and inline start button for staged convoys - Wire startConvoy mutation in TownOverviewPageClient and RigDetailPageClient - Fix convoy_id -> id field in dashboard.ui.ts (PR review comment) - Regenerate gastown tRPC type declarations * feat(gastown): add staged_convoys_default town setting Adds a town-level config toggle that forces all convoys to be created in staged mode by default. When enabled, the mayor must explicitly start each convoy before agents are dispatched. - Add staged_convoys_default to TownConfigSchema (default: false) - slingConvoy resolves staged as input.staged ?? config.staged_convoys_default - Add Switch toggle in town settings UI under new Convoys section - Add field to admin router TownConfigRecord * fix(gastown): address PR review feedback on staged convoys - Fix duplicate mayorToken DOM id in dashboard convoy panel (use convoyMayorToken) - Remove 'staged' from Convoy status enum in container plugin types (staging is tracked by the boolean field, not status) - Re-read bead after hookBead in startConvoy so assignee is up to date - Change convoy.created to convoy.started event in startConvoy * fix(gastown): address PR review feedback round 3 - Use result.convoy.staged instead of args.staged in gt_sling_batch so tool output reflects actual server state (respects town config default) - Add getConvoy tRPC query so ConvoyPanel uses a by-id query instead of filtering listConvoys (drawer no longer breaks when convoy lands) - Emit convoy.created event in slingConvoy so batch-created convoys appear in the activity feed/analytics - Fix prettier formatting on generated .d.ts files * fix(gastown): move staged flag clear to after agent-hooking loop startConvoy now clears the staged flag only after all agents are successfully hooked. If getOrCreateAgent or hookBead throws partway through, the convoy remains staged so the caller can safely retry. * fix(gastown): make startConvoy retry-safe by skipping already-hooked beads If startConvoy throws after hooking some beads, the convoy stays staged for retry. On retry, skip beads that already have an assignee to avoid duplicate hooks and orphaned agents.
1 parent 8f22340 commit 60bc2bb

24 files changed

Lines changed: 1063 additions & 46 deletions

File tree

cloudflare-gastown/container/plugin/client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
BeadType,
88
Convoy,
99
ConvoyDetail,
10+
ConvoyStartResult,
1011
GastownEnv,
1112
Mail,
1213
MayorGastownEnv,
@@ -334,13 +335,21 @@ export class MayorGastownClient {
334335
tasks: Array<{ title: string; body?: string; depends_on?: number[] }>;
335336
merge_mode?: 'review-then-land' | 'review-and-merge';
336337
parallel?: boolean;
338+
staged?: boolean;
337339
}): Promise<SlingBatchResult> {
338340
return this.request<SlingBatchResult>(this.mayorPath('/sling-batch'), {
339341
method: 'POST',
340342
body: JSON.stringify(input),
341343
});
342344
}
343345

346+
async startConvoy(convoyId: string): Promise<ConvoyStartResult> {
347+
return this.request<ConvoyStartResult>(this.mayorPath(`/convoys/${convoyId}/start`), {
348+
method: 'POST',
349+
body: JSON.stringify({}),
350+
});
351+
}
352+
344353
async listConvoys(): Promise<Convoy[]> {
345354
return this.request<Convoy[]>(this.mayorPath('/convoys'));
346355
}

cloudflare-gastown/container/plugin/mayor-tools.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
Bead,
66
Convoy,
77
ConvoyDetail,
8+
ConvoyStartResult,
89
Rig,
910
SlingBatchResult,
1011
SlingResult,
@@ -60,13 +61,26 @@ const FAKE_CONVOY: Convoy = {
6061
id: 'convoy-1',
6162
title: 'JWT Authentication',
6263
status: 'active',
64+
staged: false,
6365
total_beads: 3,
6466
closed_beads: 1,
6567
created_by: null,
6668
created_at: '2026-03-05T00:00:00Z',
6769
landed_at: null,
6870
};
6971

72+
const FAKE_STAGED_CONVOY: Convoy = {
73+
id: 'convoy-staged-1',
74+
title: 'Big Refactor',
75+
status: 'active',
76+
staged: true,
77+
total_beads: 2,
78+
closed_beads: 0,
79+
created_by: null,
80+
created_at: '2026-03-05T00:00:00Z',
81+
landed_at: null,
82+
};
83+
7084
function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): MayorGastownClient {
7185
return {
7286
sling: vi.fn<() => Promise<SlingResult>>().mockResolvedValue({
@@ -95,8 +109,22 @@ function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): Mayor
95109
],
96110
}),
97111
listConvoys: vi.fn<() => Promise<Convoy[]>>().mockResolvedValue([FAKE_CONVOY]),
112+
startConvoy: vi.fn<() => Promise<ConvoyStartResult>>().mockResolvedValue({
113+
convoy: { ...FAKE_STAGED_CONVOY, status: 'active', staged: false },
114+
beads: [
115+
{
116+
bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' },
117+
agent: { ...FAKE_AGENT, id: 'agent-1', name: 'Toast' },
118+
},
119+
{
120+
bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' },
121+
agent: { ...FAKE_AGENT, id: 'agent-2', name: 'Muffin' },
122+
},
123+
],
124+
}),
98125
getConvoyStatus: vi.fn<() => Promise<ConvoyDetail>>().mockResolvedValue({
99126
...FAKE_CONVOY,
127+
staged: false,
100128
beads: [
101129
{
102130
bead_id: 'bead-1',
@@ -339,4 +367,46 @@ describe('mayor tools', () => {
339367
expect(client.acknowledgeEscalation).toHaveBeenCalledWith('esc-1');
340368
});
341369
});
370+
371+
describe('gt_sling_batch staged', () => {
372+
it('passes staged=true to client and reports convoy as staged', async () => {
373+
client = makeFakeMayorClient({
374+
slingBatch: vi.fn<() => Promise<SlingBatchResult>>().mockResolvedValue({
375+
convoy: FAKE_STAGED_CONVOY,
376+
beads: [
377+
{
378+
bead: { ...FAKE_BEAD, bead_id: 'bead-1', title: 'Task 1' },
379+
agent: null,
380+
},
381+
{
382+
bead: { ...FAKE_BEAD, bead_id: 'bead-2', title: 'Task 2' },
383+
agent: null,
384+
},
385+
],
386+
}),
387+
});
388+
tools = createMayorTools(client);
389+
390+
const tasks = [{ title: 'Task 1' }, { title: 'Task 2', depends_on: [0] }];
391+
const result = await tools.gt_sling_batch.execute(
392+
{ rig_id: 'rig-1', convoy_title: 'Big Refactor', tasks, staged: true },
393+
CTX
394+
);
395+
396+
expect(result).toContain('Convoy staged:');
397+
expect(result).toContain('convoy-staged-1');
398+
expect(result).toContain('gt_convoy_start');
399+
expect(result).toContain('unassigned, pending gt_convoy_start');
400+
expect(client.slingBatch).toHaveBeenCalledWith(expect.objectContaining({ staged: true }));
401+
});
402+
});
403+
404+
describe('gt_convoy_start', () => {
405+
it('starts a staged convoy and reports bead count', async () => {
406+
const result = await tools.gt_convoy_start.execute({ convoy_id: 'convoy-staged-1' }, CTX);
407+
expect(result).toContain('Convoy started');
408+
expect(result).toContain('2 bead(s)');
409+
expect(client.startConvoy).toHaveBeenCalledWith('convoy-staged-1');
410+
});
411+
});
342412
});

cloudflare-gastown/container/plugin/mayor-tools.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export function createMayorTools(client: MayorGastownClient) {
169169
'that need ordering, which causes merge conflicts and failures.'
170170
)
171171
.optional(),
172+
staged: tool.schema
173+
.boolean()
174+
.describe(
175+
'If true, creates the convoy plan without dispatching agents. ' +
176+
'The user can review and edit before calling gt_convoy_start to begin execution. ' +
177+
'Default: false (dispatch immediately).'
178+
)
179+
.optional(),
172180
},
173181
async execute(args) {
174182
const result = await client.slingBatch({
@@ -177,21 +185,30 @@ export function createMayorTools(client: MayorGastownClient) {
177185
tasks: args.tasks,
178186
merge_mode: args.merge_mode,
179187
parallel: args.parallel,
188+
staged: args.staged,
180189
});
181190

182191
const beadLines = result.beads.map(
183-
(b: { bead: { title: string }; agent: { name: string; id: string } }, i: number) =>
184-
` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})`
192+
(
193+
b: { bead: { title: string }; agent: { name: string; id: string } | null },
194+
i: number
195+
) =>
196+
b.agent
197+
? ` ${i + 1}. "${b.bead.title}" → ${b.agent.name} (${b.agent.id})`
198+
: ` ${i + 1}. "${b.bead.title}" (unassigned, pending gt_convoy_start)`
185199
);
186200
const mode = args.merge_mode ?? 'review-then-land';
201+
const staged = result.convoy.staged;
187202
return [
188-
`Convoy created: "${result.convoy.title}" (${result.convoy.id})`,
203+
`Convoy ${staged ? 'staged' : 'created'}: "${result.convoy.title}" (${result.convoy.id})`,
189204
`Merge mode: ${mode}`,
190205
`Tracking ${result.convoy.total_beads} beads:`,
191206
...beadLines,
192-
mode === 'review-then-land'
193-
? `Beads will be reviewed and merged into the convoy feature branch. A final PR/merge to main occurs when all beads are done.`
194-
: `Each bead will go through the full review + merge/PR cycle independently.`,
207+
staged
208+
? `Convoy is staged — agents have NOT been dispatched. Call gt_convoy_start with convoy_id "${result.convoy.id}" when ready to begin execution.`
209+
: mode === 'review-then-land'
210+
? `Beads will be reviewed and merged into the convoy feature branch. A final PR/merge to main occurs when all beads are done.`
211+
: `Each bead will go through the full review + merge/PR cycle independently.`,
195212
].join('\n');
196213
},
197214
}),
@@ -223,6 +240,21 @@ export function createMayorTools(client: MayorGastownClient) {
223240
},
224241
}),
225242

243+
gt_convoy_start: tool({
244+
description:
245+
'Start a staged convoy. Transitions the convoy from staged (planned but not executing) ' +
246+
'to active: hooks agents to all tracked beads and begins dispatch. ' +
247+
'Call this when the user approves a staged plan and says to start it.',
248+
args: {
249+
convoy_id: tool.schema.string().describe('The UUID of the staged convoy to start'),
250+
},
251+
async execute(args) {
252+
const result = await client.startConvoy(args.convoy_id);
253+
const beadCount = result.beads?.length ?? 0;
254+
return `Convoy started. ${beadCount} bead(s) dispatched to agents.`;
255+
},
256+
}),
257+
226258
gt_mail_send: tool({
227259
description:
228260
'Send a mail message to an agent in any rig. ' +

cloudflare-gastown/container/plugin/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,33 @@ export type SlingResult = {
8888
};
8989

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

9697
// Convoy summary (returned by list and status endpoints)
98+
// Staging is tracked by the `staged` boolean, not the status field.
99+
// status tracks the convoy lifecycle: active (in progress) or landed (complete).
97100
export type Convoy = {
98101
id: string;
99102
title: string;
100103
status: 'active' | 'landed';
104+
staged: boolean;
101105
total_beads: number;
102106
closed_beads: number;
103107
created_by: string | null;
104108
created_at: string;
105109
landed_at: string | null;
106110
};
107111

112+
// Result returned by POST /convoys/:id/start
113+
export type ConvoyStartResult = {
114+
convoy: Convoy;
115+
beads: Array<{ bead: Bead; agent: Agent }>;
116+
};
117+
108118
// Detailed convoy status with per-bead breakdown
109119
export type ConvoyDetail = Convoy & {
110120
beads: Array<{

cloudflare-gastown/src/db/tables/convoy-metadata.table.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const ConvoyMetadataRecord = z.object({
1919
* individually, like standalone beads.
2020
*/
2121
merge_mode: ConvoyMergeMode.nullable(),
22+
/** 1 = staged (planned, agents not dispatched), 0 = active (SQLite boolean) */
23+
staged: z.number().int().default(0),
2224
});
2325

2426
export type ConvoyMetadataRecord = z.output<typeof ConvoyMetadataRecord>;
@@ -32,14 +34,16 @@ export function createTableConvoyMetadata(): string {
3234
closed_beads: `integer not null default 0`,
3335
landed_at: `text`,
3436
feature_branch: `text`,
35-
merge_mode: `text`,
37+
merge_mode: `text check(merge_mode in ('review-then-land', 'review-and-merge'))`,
38+
staged: `integer not null default 0`,
3639
});
3740
}
3841

3942
/** Idempotent ALTER statements for existing databases. */
4043
export function migrateConvoyMetadata(): string[] {
4144
return [
4245
`ALTER TABLE convoy_metadata ADD COLUMN feature_branch text`,
43-
`ALTER TABLE convoy_metadata ADD COLUMN merge_mode text`,
46+
`ALTER TABLE convoy_metadata ADD COLUMN merge_mode text check(merge_mode in ('review-then-land', 'review-and-merge'))`,
47+
`ALTER TABLE convoy_metadata ADD COLUMN staged integer not null default 0`,
4448
];
4549
}

0 commit comments

Comments
 (0)