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
32 changes: 32 additions & 0 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ export class MayorGastownClient {
title: string;
body?: string;
metadata?: Record<string, unknown>;
depends_on?: string[];
convoy_id?: string;
}): Promise<SlingResult> {
return this.request<SlingResult>(this.mayorPath('/sling'), {
method: 'POST',
Expand Down Expand Up @@ -381,6 +383,35 @@ export class MayorGastownClient {
);
}

async addBeadDependency(input: {
rig_id: string;
bead_id: string;
depends_on_bead_id: string;
dependency_type?: 'blocks' | 'tracks' | 'parent-child';
}): Promise<void> {
await this.request<{ ok: true }>(
`${this.baseUrl}/api/towns/${this.townId}/rigs/${input.rig_id}/beads/${input.bead_id}/dependencies`,
{
method: 'POST',
body: JSON.stringify({
depends_on_bead_id: input.depends_on_bead_id,
dependency_type: input.dependency_type,
}),
}
);
}

async removeBeadDependency(input: {
rig_id: string;
bead_id: string;
depends_on_bead_id: string;
}): Promise<void> {
await this.request<{ ok: true; deleted: boolean }>(
`${this.baseUrl}/api/towns/${this.townId}/rigs/${input.rig_id}/beads/${input.bead_id}/dependencies/${input.depends_on_bead_id}`,
{ method: 'DELETE' }
);
}

async listConvoys(): Promise<Convoy[]> {
return this.request<Convoy[]>(this.mayorPath('/convoys'));
}
Expand All @@ -398,6 +429,7 @@ export class MayorGastownClient {
status?: 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
priority?: 'low' | 'medium' | 'high' | 'critical';
labels?: string[];
convoy_id?: string | null;
}
): Promise<Bead> {
return this.request<Bead>(this.mayorPath(`/rigs/${rigId}/beads/${beadId}`), {
Expand Down
89 changes: 85 additions & 4 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,46 @@ export function createMayorTools(client: MayorGastownClient) {
.string()
.describe('JSON-encoded metadata object for additional context')
.optional(),
depends_on: tool.schema
.array(tool.schema.string())
.describe(
'Optional list of bead IDs this task depends on. The new bead will not be dispatched until all listed beads are closed.'
)
.optional(),
convoy_id: tool.schema
.string()
.describe(
'Optional convoy ID to add this bead to. The bead will be tracked by the convoy and included in its progress.'
)
.optional(),
},
async execute(args) {
const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined;
// Pass depends_on directly to client.sling() so TownDO.slingBead()
// inserts the dependency rows atomically before arming dispatch.
const result = await client.sling({
rig_id: args.rig_id,
title: args.title,
body: args.body,
metadata,
depends_on: args.depends_on,
convoy_id: args.convoy_id,
});
return [

const lines = [
`Task slung successfully.`,
`Bead: ${result.bead.bead_id} — "${result.bead.title}"`,
`Assigned to: ${result.agent.name} (${result.agent.role}, id: ${result.agent.id})`,
`Status: ${result.bead.status}`,
`The polecat will be dispatched automatically by the alarm scheduler.`,
].join('\n');
];
if (args.depends_on && args.depends_on.length > 0) {
lines.push(`Dependencies: blocked by ${args.depends_on.length} bead(s)`);
}
if (args.convoy_id) {
lines.push(`Convoy: added to ${args.convoy_id}`);
}
lines.push(`The polecat will be dispatched automatically by the alarm scheduler.`);
return lines.join('\n');
},
}),

Expand Down Expand Up @@ -304,7 +328,9 @@ export function createMayorTools(client: MayorGastownClient) {
}),

gt_bead_update: tool({
description: "Edit a bead's status, title, body, priority, or labels.",
description:
"Edit a bead's status, title, body, priority, labels, or convoy membership. " +
'Set convoy_id to add the bead to a convoy, or set it to null/empty to remove it.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the bead belongs to'),
bead_id: tool.schema.string().describe('The UUID of the bead to update'),
Expand All @@ -322,6 +348,13 @@ export function createMayorTools(client: MayorGastownClient) {
.array(tool.schema.string())
.describe('Replacement labels array for the bead')
.optional(),
convoy_id: tool.schema
.string()
.describe(
'Set to a convoy UUID to add this bead to that convoy. ' +
'Set to an empty string to remove the bead from its current convoy.'
)
.optional(),
},
async execute(args) {
const bead = await client.updateBead(args.rig_id, args.bead_id, {
Expand All @@ -330,6 +363,7 @@ export function createMayorTools(client: MayorGastownClient) {
status: args.status,
priority: args.priority,
labels: args.labels,
convoy_id: args.convoy_id === '' ? null : args.convoy_id,
});
return `Bead ${bead.bead_id} updated. Status: ${bead.status}, Priority: ${bead.priority}, Title: "${bead.title}".`;
},
Expand Down Expand Up @@ -475,5 +509,52 @@ export function createMayorTools(client: MayorGastownClient) {
return `Nudge queued: ${result.nudge_id} (mode: ${args.mode ?? 'wait-idle'})`;
},
}),

gt_bead_add_dependency: tool({
description:
'Add a dependency between two beads. The bead at bead_id will be blocked by depends_on_bead_id — ' +
'it will not be dispatched until the dependency is closed.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'),
bead_id: tool.schema.string().describe('The UUID of the bead that should be blocked'),
depends_on_bead_id: tool.schema
.string()
.describe('The UUID of the bead that must close first'),
dependency_type: tool.schema
.enum(['blocks', 'parent-child'])
.describe('Type of dependency (default: blocks)')
.optional(),
},
async execute(args) {
await client.addBeadDependency({
rig_id: args.rig_id,
bead_id: args.bead_id,
depends_on_bead_id: args.depends_on_bead_id,
dependency_type: args.dependency_type ?? 'blocks',
});
return `Dependency added: bead ${args.bead_id} now depends on ${args.depends_on_bead_id} (type: ${args.dependency_type ?? 'blocks'}).`;
},
}),

gt_bead_remove_dependency: tool({
description:
'Remove a dependency between two beads. If removing the dependency unblocks the bead, ' +
'it will be dispatched automatically.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'),
bead_id: tool.schema.string().describe('The UUID of the dependent bead'),
depends_on_bead_id: tool.schema
.string()
.describe('The UUID of the bead it currently depends on'),
},
async execute(args) {
await client.removeBeadDependency({
rig_id: args.rig_id,
bead_id: args.bead_id,
depends_on_bead_id: args.depends_on_bead_id,
});
return `Dependency removed: bead ${args.bead_id} no longer depends on ${args.depends_on_bead_id}. If this was the last blocker, the bead will be dispatched automatically.`;
},
}),
};
}
2 changes: 2 additions & 0 deletions cloudflare-gastown/src/db/tables/bead-events.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const BeadEventType = z.enum([
'review_queue_depth_alert',
'escalation_rate_spike',
'agent_restart_loop',
'dependency_added',
'dependency_removed',
]);

export type BeadEventType = z.infer<typeof BeadEventType>;
Expand Down
110 changes: 105 additions & 5 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,72 @@ export class TownDO extends DurableObject<Env> {
return bead;
}

// ── Bead Dependency Editing ──────────────────────────────────────────

/**
* Add a dependency edge between two beads.
* Validates, detects cycles, and logs a bead event.
*/
async addBeadDependency(
beadId: string,
dependsOnBeadId: string,
type: 'blocks' | 'tracks' | 'parent-child'
): Promise<void> {
await this.ensureInitialized();
beadOps.addBeadDependency(this.sql, beadId, dependsOnBeadId, type);
beadOps.logBeadEvent(this.sql, {
beadId,
agentId: null,
eventType: 'dependency_added',
metadata: { depends_on_bead_id: dependsOnBeadId, dependency_type: type },
});
}

/**
* Remove a dependency edge between two beads.
* After removal, checks if any beads are now unblocked and arms the
* alarm so they get dispatched promptly.
*/
async removeBeadDependency(beadId: string, dependsOnBeadId: string): Promise<boolean> {
await this.ensureInitialized();
const deleted = beadOps.removeBeadDependency(this.sql, beadId, dependsOnBeadId);
if (deleted) {
beadOps.logBeadEvent(this.sql, {
beadId,
agentId: null,
eventType: 'dependency_removed',
metadata: { depends_on_bead_id: dependsOnBeadId },
});
// If beadId has no remaining unresolved blockers, arm the alarm so
// it gets dispatched promptly.
if (!beadOps.hasUnresolvedBlockers(this.sql, beadId)) {
await this.ctx.storage.setAlarm(Date.now());
}
}
return deleted;
}

// ── Convoy Membership ────────────────────────────────────────────────

/**
* Add a bead to an existing convoy. Creates the 'tracks' dependency,
* merges convoy metadata into the bead, and increments total_beads.
*/
async addBeadToConvoy(beadId: string, convoyId: string): Promise<void> {
await this.ensureInitialized();
beadOps.addBeadToConvoy(this.sql, beadId, convoyId);
}

/**
* Remove a bead from its convoy. Deletes the 'tracks' dependency,
* strips convoy metadata, and decrements total_beads.
* Returns the convoy ID the bead was removed from, or null if not in a convoy.
*/
async removeBeadFromConvoy(beadId: string): Promise<string | null> {
await this.ensureInitialized();
return beadOps.removeBeadFromConvoy(this.sql, beadId);
}

/**
* Force-reset an agent to idle, unhooking from its current bead if any.
* Sets the bead status back to 'open' so it can be re-dispatched.
Expand Down Expand Up @@ -1631,9 +1697,21 @@ export class TownDO extends DurableObject<Env> {
body?: string;
priority?: string;
metadata?: Record<string, unknown>;
dependsOn?: string[];
convoyId?: string;
}): Promise<{ bead: Bead; agent: Agent }> {
await this.ensureInitialized();

// Validate the convoy exists before creating the bead so a bad
// convoy_id doesn't leave behind an orphan bead row.
if (input.convoyId) {
const convoyBead = beadOps.getBead(this.sql, input.convoyId);
if (!convoyBead) throw new Error(`Convoy ${input.convoyId} not found`);
if (convoyBead.type !== 'convoy') {
throw new Error(`Bead ${input.convoyId} is not a convoy (type: ${convoyBead.type})`);
}
}

const createdBead = beadOps.createBead(this.sql, {
type: 'issue',
title: input.title,
Expand All @@ -1643,18 +1721,40 @@ export class TownDO extends DurableObject<Env> {
metadata: input.metadata,
});

// If a convoy_id was provided, add the bead to the convoy (tracks dep + metadata + counter).
// The convoy was already validated above, so addBeadToConvoy won't throw for a missing convoy.
if (input.convoyId) {
beadOps.addBeadToConvoy(this.sql, createdBead.bead_id, input.convoyId);
Comment thread
jrf0110 marked this conversation as resolved.
}

// Insert dependency rows before hooking/dispatching so the bead's
// blocker set is complete before any agent can start work on it.
// This is atomic within the DO's synchronous SQLite transaction.
if (input.dependsOn && input.dependsOn.length > 0) {
for (const depBeadId of input.dependsOn) {
beadOps.addBeadDependency(this.sql, createdBead.bead_id, depBeadId, 'blocks');
}
}

const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId);
agents.hookBead(this.sql, agent.id, createdBead.bead_id);

// Re-read bead and agent after hook (hookBead updates both)
const bead = beadOps.getBead(this.sql, createdBead.bead_id) ?? createdBead;
const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent;

// Fire-and-forget dispatch so the sling call returns immediately.
// The alarm loop retries if this fails.
this.dispatchAgent(hookedAgent, bead).catch(err =>
console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err)
);
// Only dispatch if the bead has no unresolved blockers. Mirror the
// slingConvoy() guard so a bead with depends_on is not started before
// its blockers close.
if (!beadOps.hasUnresolvedBlockers(this.sql, bead.bead_id)) {
this.dispatchAgent(hookedAgent, bead).catch(err =>
console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err)
);
} else {
console.log(
`${TOWN_LOG} slingBead: bead=${bead.bead_id} blocked, deferring dispatch until deps close`
);
}
await this.armAlarmIfNeeded();
return { bead, agent: hookedAgent };
}
Expand Down
Loading
Loading