Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions services/gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export class MayorGastownClient {
status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
priority?: 'low' | 'medium' | 'high' | 'critical';
labels?: string[];
depends_on?: string[];
}
): Promise<Bead> {
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
Expand All @@ -417,6 +418,27 @@ export class MayorGastownClient {
});
}

async convoyAddBead(
convoyId: string,
beadId: string,
dependsOn?: string[]
): Promise<{ total_beads: number }> {
return this.request<{ total_beads: number }>(this.mayorPath(`/convoys/${convoyId}/add-bead`), {
method: 'POST',
body: JSON.stringify({ bead_id: beadId, depends_on: dependsOn }),
});
}

async convoyRemoveBead(convoyId: string, beadId: string): Promise<{ total_beads: number }> {
return this.request<{ total_beads: number }>(
this.mayorPath(`/convoys/${convoyId}/remove-bead`),
{
method: 'POST',
body: JSON.stringify({ bead_id: beadId }),
}
);
}

async reassignBead(rigId: string, beadId: string, agentId: string): Promise<Bead> {
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}/reassign`), {
method: 'POST',
Expand Down
44 changes: 43 additions & 1 deletion services/gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export function createMayorTools(client: MayorGastownClient) {
}),

gt_bead_update: tool({
description: "Edit a bead's status, title, body, priority, or labels.",
description: "Edit a bead's status, title, body, priority, labels, or dependency blockers.",
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'),
bead_id: tool.schema.string().describe('The UUID of the bead to update'),
Expand All @@ -310,6 +310,13 @@ export function createMayorTools(client: MayorGastownClient) {
.array(tool.schema.string())
.describe('Replacement labels array for the bead')
.optional(),
depends_on: tool.schema
.array(tool.schema.string())
.describe(
"Replace this bead's blockers. Pass an array of bead UUIDs that must be closed before this bead can be dispatched. " +
'Pass an empty array [] to remove all blockers. Omit to leave dependencies unchanged.'
)
.optional(),
},
async execute(args) {
const bead = await client.updateBead(args.rig_id, args.bead_id, {
Expand All @@ -318,6 +325,7 @@ export function createMayorTools(client: MayorGastownClient) {
status: args.status,
priority: args.priority,
labels: args.labels,
depends_on: args.depends_on,
});
return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`;
},
Expand Down Expand Up @@ -379,6 +387,40 @@ export function createMayorTools(client: MayorGastownClient) {
},
}),

gt_convoy_add_bead: tool({
description:
'Add an existing bead to an existing convoy. Use this after gt_sling to make a standalone bead ' +
"part of a convoy's tracked progress and landing. The bead will count toward the convoy's " +
'completion and will be included in the convoy landing.',
args: {
convoy_id: tool.schema.string().describe('UUID of the convoy to add the bead to'),
bead_id: tool.schema.string().describe('UUID of the bead to add'),
depends_on: tool.schema
.array(tool.schema.string())
.describe('Optional: bead UUIDs that must complete before this bead is dispatched')
.optional(),
},
async execute(args) {
const result = await client.convoyAddBead(args.convoy_id, args.bead_id, args.depends_on);
return `Bead ${args.bead_id} added to convoy ${args.convoy_id}. Convoy now tracking ${result.total_beads} beads.`;
},
}),

gt_convoy_remove_bead: tool({
description:
'Remove a bead from a convoy. The bead will no longer count toward convoy progress or landing. ' +
'Dependency edges between this bead and other convoy beads are also removed. ' +
'The bead itself is not deleted — it becomes a standalone bead.',
args: {
convoy_id: tool.schema.string().describe('UUID of the convoy'),
bead_id: tool.schema.string().describe('UUID of the bead to remove from the convoy'),
},
async execute(args) {
const result = await client.convoyRemoveBead(args.convoy_id, args.bead_id);
return `Bead ${args.bead_id} removed from convoy ${args.convoy_id}. Convoy now tracking ${result.total_beads} beads.`;
},
}),

gt_convoy_update: tool({
description: 'Edit convoy metadata (merge_mode, feature_branch).',
args: {
Expand Down
69 changes: 68 additions & 1 deletion services/gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,7 @@ export class TownDO extends DurableObject<Env> {
labels: string[];
status: BeadStatus;
metadata: Record<string, unknown>;
depends_on: string[];
}>,
actorId: string
): Promise<Bead> {
Expand All @@ -1195,7 +1196,12 @@ export class TownDO extends DurableObject<Env> {
});
}

const bead = beadOps.updateBeadFields(this.sql, beadId, fields, actorId);
const { depends_on, ...beadFields } = fields;
const bead = beadOps.updateBeadFields(this.sql, beadId, beadFields, actorId);

if (depends_on !== undefined) {
beadOps.setDependencies(this.sql, beadId, depends_on);
}

// When a bead closes via field update, check for newly unblocked beads
if (fields.status === 'closed' || fields.status === 'failed') {
Expand All @@ -1205,6 +1211,67 @@ export class TownDO extends DurableObject<Env> {
return bead;
}

/** Add an existing bead to a convoy's tracking. Returns updated convoy metadata. */
async convoyAddBead(
convoyId: string,
beadId: string,
dependsOn?: string[]
): Promise<{ total_beads: number }> {
const convoyCheck = [
...query(
this.sql,
/* sql */ `SELECT 1 FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} = ?`,
[convoyId]
),
];
if (convoyCheck.length === 0) throw new Error(`Bead ${convoyId} is not a convoy`);
beadOps.convoyAddBead(this.sql, convoyId, beadId);
Comment thread
jrf0110 marked this conversation as resolved.
if (dependsOn !== undefined) {
beadOps.setDependencies(this.sql, beadId, dependsOn);
}
const rows = [
...query(
this.sql,
/* sql */ `
SELECT ${convoy_metadata.total_beads}
FROM ${convoy_metadata}
WHERE ${convoy_metadata.bead_id} = ?
`,
[convoyId]
),
];
const parsed = z.object({ total_beads: z.number() }).array().parse(rows);
const total = parsed[0]?.total_beads ?? 0;
return { total_beads: total };
}

/** Remove a bead from a convoy's tracking. Returns updated convoy metadata. */
async convoyRemoveBead(convoyId: string, beadId: string): Promise<{ total_beads: number }> {
const convoyCheck = [
...query(
this.sql,
/* sql */ `SELECT 1 FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} = ?`,
[convoyId]
),
];
if (convoyCheck.length === 0) throw new Error(`Bead ${convoyId} is not a convoy`);
beadOps.convoyRemoveBead(this.sql, convoyId, beadId);
const rows = [
...query(
this.sql,
/* sql */ `
SELECT ${convoy_metadata.total_beads}
FROM ${convoy_metadata}
WHERE ${convoy_metadata.bead_id} = ?
`,
[convoyId]
),
];
const parsed = z.object({ total_beads: z.number() }).array().parse(rows);
const total = parsed[0]?.total_beads ?? 0;
return { total_beads: total };
}

/**
* Force-reset an agent to idle, unhooking from its current bead if any.
* Sets the bead status back to 'open' so it can be re-dispatched.
Expand Down
172 changes: 172 additions & 0 deletions services/gastown/src/dos/town/beads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,178 @@ export function insertDependency(
);
}

/**
* Atomically replace all 'blocks' edges for a bead.
* Deletes existing blockers then inserts the provided list.
* Self-loops are silently skipped.
*/
export function setDependencies(sql: SqlStorage, beadId: string, dependsOnBeadIds: string[]): void {
query(
sql,
/* sql */ `
DELETE FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'blocks'
`,
[beadId]
);
for (const depId of dependsOnBeadIds) {
if (depId === beadId) continue; // no self-loops
query(
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 (?, ?, 'blocks')
`,
[beadId, depId]
);
}
}

/**
* Add a bead to a convoy's tracking.
* Inserts a 'tracks' edge and increments total_beads — but only when the
* edge is genuinely new (INSERT OR IGNORE returns 0 changes if it already
* exists, so we check before updating the counter).
* Returns whether the bead was newly added (true) or already tracked (false).
*/
export function convoyAddBead(sql: SqlStorage, convoyId: string, beadId: string): boolean {
// Check if already tracked
const existing = [
...query(
sql,
/* sql */ `
SELECT 1 FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'tracks'
`,
[beadId, convoyId]
),
];
if (existing.length > 0) return false;

query(
sql,
/* sql */ `
INSERT INTO ${bead_dependencies} (
${bead_dependencies.columns.bead_id},
${bead_dependencies.columns.depends_on_bead_id},
${bead_dependencies.columns.dependency_type}
) VALUES (?, ?, 'tracks')
`,
[beadId, convoyId]
);
query(
sql,
/* sql */ `
UPDATE ${convoy_metadata}
SET ${convoy_metadata.columns.total_beads} = ${convoy_metadata.columns.total_beads} + 1
WHERE ${convoy_metadata.bead_id} = ?
`,
[convoyId]
);
return true;
}

/**
* Remove a bead from a convoy's tracking.
* Deletes the 'tracks' edge, decrements total_beads (floor 0), and removes
* any 'blocks' edges between this bead and other beads in the same convoy.
* Returns whether the bead was actually tracked (true) or not found (false).
*/
export function convoyRemoveBead(sql: SqlStorage, convoyId: string, beadId: string): boolean {
// Check if tracked
const existing = [
...query(
sql,
/* sql */ `
SELECT 1 FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'tracks'
`,
[beadId, convoyId]
),
];
if (existing.length === 0) return false;

// Remove the tracks edge
query(
sql,
/* sql */ `
DELETE FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'tracks'
`,
[beadId, convoyId]
);
// Decrement total_beads, floor at 0
query(
sql,
/* sql */ `
UPDATE ${convoy_metadata}
SET ${convoy_metadata.columns.total_beads} = MAX(0, ${convoy_metadata.columns.total_beads} - 1)
WHERE ${convoy_metadata.bead_id} = ?
`,
[convoyId]
);

// Find all other beads tracked by this convoy
const siblingRows = [
...query(
sql,
/* sql */ `
SELECT ${bead_dependencies.bead_id}
FROM ${bead_dependencies}
WHERE ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'tracks'
`,
[convoyId]
),
];
const siblingIds = z
.object({ bead_id: z.string() })
.array()
.parse(siblingRows)
.map(r => r.bead_id);

if (siblingIds.length > 0) {
// Delete 'blocks' edges where beadId is the blocker of a sibling
for (const siblingId of siblingIds) {
query(
sql,
/* sql */ `
DELETE FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'blocks'
`,
[siblingId, beadId]
);
}
// Delete 'blocks' edges where beadId depends on a sibling
for (const siblingId of siblingIds) {
query(
sql,
/* sql */ `
DELETE FROM ${bead_dependencies}
WHERE ${bead_dependencies.bead_id} = ?
AND ${bead_dependencies.depends_on_bead_id} = ?
AND ${bead_dependencies.dependency_type} = 'blocks'
`,
[beadId, siblingId]
);
}
}

return true;
}

/**
* Find beads that were blocked by `closedBeadId` and are now fully unblocked
* (all their 'blocks' dependencies are resolved).
Expand Down
Loading