Skip to content
Merged
4 changes: 2 additions & 2 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ export function createMayorTools(client: MayorGastownClient) {
gt_list_beads: tool({
description:
'List beads (work items) in a specific rig. ' +
'Optionally filter by status (open, in_progress, closed, failed) or type (issue, message, escalation, merge_request). ' +
'Optionally filter by status (open, in_progress, in_review, closed, failed) or type (issue, message, escalation, merge_request). ' +
'Use this to check what work exists in a rig, what is in progress, and what has been completed.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig to list beads from'),
status: tool.schema
.enum(['open', 'in_progress', 'closed', 'failed'])
.enum(['open', 'in_progress', 'in_review', 'closed', 'failed'])
.describe('Filter by bead status')
.optional(),
type: tool.schema
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/container/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Types mirroring the Town DO domain model.
// These are the API response shapes — the plugin never touches SQLite directly.

export type BeadStatus = 'open' | 'in_progress' | 'closed' | 'failed';
export type BeadStatus = 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
export type BeadType =
| 'issue'
| 'message'
Expand Down
1 change: 1 addition & 0 deletions cloudflare-gastown/src/db/tables/bead-events.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const BeadEventType = z.enum([
'pr_created',
'pr_creation_failed',
'agent_status',
'triage_resolved',
]);

export type BeadEventType = z.infer<typeof BeadEventType>;
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/src/db/tables/beads.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const BeadType = z.enum([
'agent',
]);

export const BeadStatus = z.enum(['open', 'in_progress', 'closed', 'failed']);
export const BeadStatus = z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']);
export const BeadPriority = z.enum(['low', 'medium', 'high', 'critical']);

export const BeadRecord = z.object({
Expand Down
4 changes: 2 additions & 2 deletions cloudflare-gastown/src/db/tables/rig-beads.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';
import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table';

const BeadType = z.enum(['issue', 'message', 'escalation', 'merge_request']);
const BeadStatus = z.enum(['open', 'in_progress', 'closed', 'failed']);
const BeadStatus = z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']);
const BeadPriority = z.enum(['low', 'medium', 'high', 'critical']);

export const RigBeadRecord = z.object({
Expand Down Expand Up @@ -32,7 +32,7 @@ export function createTableRigBeads(): string {
id: `text primary key`,
rig_id: `text`,
type: `text not null check(type in ('issue', 'message', 'escalation', 'merge_request'))`,
status: `text not null default 'open' check(status in ('open', 'in_progress', 'closed', 'failed'))`,
status: `text not null default 'open' check(status in ('open', 'in_progress', 'in_review', 'closed', 'failed'))`,
title: `text not null`,
body: `text`,
assignee_agent_id: `text`,
Expand Down
54 changes: 52 additions & 2 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,36 @@ export class TownDO extends DurableObject<Env> {
if (input.status === 'merged' && sourceBeadId) {
this.dispatchUnblockedBeads(sourceBeadId);
}

// When a review fails or conflicts (rework), the source bead was
// returned to in_progress. Re-hook a polecat and re-dispatch so the
// rework starts automatically. The original polecat may already be
// working on something else, so fall back to getOrCreateAgent.
if ((input.status === 'failed' || input.status === 'conflict') && sourceBeadId) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: failed reviews are not always source-bead rework

This block re-dispatches a polecat for every failed review, but completeReviewWithResult(..., { status: 'failed' }) is also used for refinery-side failures like an invalid pr_url and for PRs that were closed without merge. In those cases the source bead's code may already be correct, so auto-reopening it here can send a polecat back onto finished work and create duplicate review loops.

const sourceBead = beadOps.getBead(this.sql, sourceBeadId);
if (sourceBead?.rig_id) {
try {
const reworkAgent = agents.getOrCreateAgent(
this.sql,
'polecat',
sourceBead.rig_id,
this.townId
);
agents.hookBead(this.sql, reworkAgent.id, sourceBeadId);
this.dispatchAgent(reworkAgent, sourceBead).catch(err =>
console.error(
`${TOWN_LOG} completeReviewWithResult: fire-and-forget rework dispatch failed for bead=${sourceBeadId}`,
err
)
);
} catch (err) {
console.warn(
`${TOWN_LOG} completeReviewWithResult: could not dispatch rework for bead=${sourceBeadId}:`,
err
);
}
}
}
}

async agentDone(agentId: string, input: AgentDoneInput): Promise<void> {
Expand Down Expand Up @@ -966,6 +996,24 @@ export class TownDO extends DurableObject<Env> {
},
});

// Log a triage_resolved event on the target bead so the action shows
// up in the activity feed for the bead that was actually affected.
const targetBeadId = snapshotHookedBeadId ?? targetAgentId;
if (targetBeadId && targetBeadId !== input.triage_request_bead_id) {
beadOps.logBeadEvent(this.sql, {
beadId: targetBeadId,
agentId: input.agent_id,
eventType: 'triage_resolved',
newValue: action,
metadata: {
action,
resolution_notes: input.resolution_notes,
triage_request_bead_id: input.triage_request_bead_id,
target_agent_id: targetAgentId,
},
});
}

// If this triage request was created for an escalation, close the
// linked escalation bead too so it doesn't sit open indefinitely.
// The escalation_bead_id is nested under metadata.context (set by
Expand Down Expand Up @@ -3245,7 +3293,7 @@ export class TownDO extends DurableObject<Env> {
[
...query(
this.sql,
/* sql */ `SELECT COUNT(*) AS cnt FROM ${beads} WHERE ${beads.status} IN ('open', 'in_progress') AND ${beads.type} NOT IN ('agent', 'message')`,
/* sql */ `SELECT COUNT(*) AS cnt FROM ${beads} WHERE ${beads.status} IN ('open', 'in_progress', 'in_review') AND ${beads.type} NOT IN ('agent', 'message')`,
[]
),
][0]?.cnt ?? 0
Expand Down Expand Up @@ -3274,6 +3322,7 @@ export class TownDO extends DurableObject<Env> {
beads: {
open: number;
inProgress: number;
inReview: number;
Comment thread
jrf0110 marked this conversation as resolved.
failed: number;
triageRequests: number;
};
Expand Down Expand Up @@ -3328,12 +3377,13 @@ export class TownDO extends DurableObject<Env> {
[]
),
];
const beadCounts = { open: 0, inProgress: 0, failed: 0, triageRequests: 0 };
const beadCounts = { open: 0, inProgress: 0, inReview: 0, failed: 0, triageRequests: 0 };
for (const row of beadRows) {
const s = `${row.status as string}`;
const c = Number(row.cnt);
if (s === 'open') beadCounts.open = c;
else if (s === 'in_progress') beadCounts.inProgress = c;
else if (s === 'in_review') beadCounts.inReview = c;
else if (s === 'failed') beadCounts.failed = c;
}

Expand Down
19 changes: 19 additions & 0 deletions cloudflare-gastown/src/dos/town/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,32 @@ export function deleteAgent(sql: SqlStorage, agentId: string): void {

// ── Hooks (GUPP) ────────────────────────────────────────────────────

/** Bead types that are system-managed and should never be hooked to an agent. */
const UNHOOKABLE_BEAD_TYPES = new Set(['escalation', 'convoy', 'agent', 'message']);

export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void {
const agent = getAgent(sql, agentId);
if (!agent) throw new Error(`Agent ${agentId} not found`);

const bead = getBead(sql, beadId);
if (!bead) throw new Error(`Bead ${beadId} not found`);

// Prevent hooking to system-managed bead types that no agent should
// work on directly. Escalation beads are resolved by triage, convoy
// beads are containers, agent/message beads are metadata records.
if (UNHOOKABLE_BEAD_TYPES.has(bead.type)) {
throw new Error(`Cannot hook agent to bead ${beadId}: type '${bead.type}' is not workable`);
}

// Triage request beads are resolved by the triage agent via
// gt_triage_resolve, not by hooking. Prevent polecats from
// accidentally picking these up.
if (bead.labels.includes('gt:triage-request')) {
throw new Error(
`Cannot hook agent to bead ${beadId}: triage requests are resolved via gt_triage_resolve`
);
}

// Already hooked to this bead — idempotent
if (agent.current_hook_bead_id === beadId) return;

Expand Down
17 changes: 13 additions & 4 deletions cloudflare-gastown/src/dos/town/review-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ export function completeReviewWithResult(
conflict: true,
},
});
// Return source bead to in_progress so the polecat can be re-dispatched
// to resolve the conflict (in_review → in_progress rework flow).
updateBeadStatus(sql, entry.bead_id, 'in_progress', entry.agent_id);
} else if (input.status === 'failed') {
// Review failed (rework requested): return source bead to in_progress
// so it can be re-dispatched (in_review → in_progress rework flow).
updateBeadStatus(sql, entry.bead_id, 'in_progress', entry.agent_id);
}
}

Expand Down Expand Up @@ -556,11 +563,13 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu
default_branch: rig?.default_branch,
});

// Close the source bead (matches upstream gt done behavior). The polecat's
// work is done — the MR bead now tracks the merge lifecycle. The source
// bead retains its assignee so we know which agent worked on it.
// Transition the source bead to in_review — the polecat's work is done
// but the refinery hasn't reviewed it yet. The MR bead tracks the merge
// lifecycle. The source bead retains its assignee so we know which agent
// worked on it. It will be closed (or returned to in_progress) by the
// refinery after review.
unhookBead(sql, agentId);
closeBead(sql, sourceBead, agentId);
updateBeadStatus(sql, sourceBead, 'in_review', agentId);
Comment thread
jrf0110 marked this conversation as resolved.
}

/**
Expand Down
8 changes: 7 additions & 1 deletion cloudflare-gastown/src/handlers/rig-triage.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ const ResolveTriageBody = z.object({
});

export async function handleResolveTriage(c: Context<GastownEnv>, _params: { rigId: string }) {
const agentId = getEnforcedAgentId(c);
// In production, agentId comes from the verified JWT. In development
// (where authMiddleware is skipped), fall back to the identity header
// the container client sends with every request. The fallback is gated
// on ENVIRONMENT to prevent header spoofing in production.
const agentId =
getEnforcedAgentId(c) ||
(c.env.ENVIRONMENT === 'development' ? c.req.header('X-Gastown-Agent-Id') : null);
if (!agentId) {
return c.json(resError('Agent authentication required'), 401);
}
Expand Down
2 changes: 2 additions & 0 deletions cloudflare-gastown/src/prompts/mayor-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ When a user asks "how's X going?" or wants a progress update:

Convoys land automatically when all tracked beads close — no manual management needed.

Bead lifecycle: \`open\` → \`in_progress\` (polecat working) → \`in_review\` (gt_done called, awaiting refinery) → \`closed\` (merged) or back to \`in_progress\` (rework requested).

## Conversational Model

- **Respond directly for questions.** If the user asks a question you can answer from context, respond conversationally. Don't delegate questions.
Expand Down
4 changes: 2 additions & 2 deletions cloudflare-gastown/src/prompts/polecat-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You have these tools available. Use them to coordinate with the Gastown orchestr
- **gt_prime** — Call at the start of your session to get full context: your agent record, hooked bead, undelivered mail, and open beads. Your context is injected automatically on first message, but call this if you need to refresh.
- **gt_bead_status** — Inspect the current state of any bead by ID.
- **gt_bead_close** — Close a bead when its work is fully complete and merged.
- **gt_done** — Signal that you are done with your current hooked bead. This pushes your branch, submits it to the review queue, and unhooks you. Always push your branch before calling gt_done.
- **gt_done** — Signal that you are done with your current hooked bead. This pushes your branch, submits it to the review queue, transitions the bead to \`in_review\`, and unhooks you. Always push your branch before calling gt_done.
- **gt_mail_send** — Send a message to another agent in the rig. Use this for coordination, questions, or status sharing.
- **gt_mail_check** — Check for new mail from other agents. Call this periodically or when you suspect coordination messages.
- **gt_escalate** — Escalate a problem you cannot solve. Creates an escalation bead. Use this when you are stuck, blocked, or need human intervention.
Expand All @@ -37,7 +37,7 @@ You have these tools available. Use them to coordinate with the Gastown orchestr
2. **Work**: Implement the bead's requirements. Write code, tests, and documentation as needed.
3. **Commit frequently**: Make small, focused commits. Push often. The container's disk is ephemeral — if it restarts, unpushed work is lost.
4. **Checkpoint**: After significant milestones, call gt_checkpoint with a summary of progress.
5. **Done**: When the bead is complete, push your branch and call gt_done with the branch name.
5. **Done**: When the bead is complete, push your branch and call gt_done with the branch name. The bead transitions to \`in_review\` and the refinery picks it up for merge. If the review fails (rework), you will be re-dispatched with the bead back in \`in_progress\`.

## Commit & Push Hygiene

Expand Down
2 changes: 2 additions & 0 deletions cloudflare-gastown/src/prompts/triage-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ This will close the triage batch, unhook you, and return you to idle.
- **Prefer least-disruptive actions.** RESTART over CLOSE_BEAD. NUDGE over ESCALATE.
- **Escalate genuinely hard problems.** If a situation requires human context you don't have, escalate rather than guess.
- **Never skip a triage request.** Every pending request must be resolved.
- **Post status updates.** Call gt_status before starting the batch (e.g. "Triaging 3 requests") and after finishing (e.g. "Triage complete — 2 restarted, 1 escalated"). This keeps the dashboard informed.

## Available Tools

- **gt_triage_resolve** — Resolve a triage request. Provide the triage_request_bead_id, chosen action, and brief notes.
- **gt_status** — Post a plain-language status update visible on the dashboard. Call this at the start and end of your triage batch.
- **gt_mail_send** — Send guidance to a stuck agent.
- **gt_escalate** — Forward a problem to the Mayor or human operators.
- **gt_bead_close** — Close your hooked bead when all triage requests have been processed.
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export const gastownRouter = router({
.input(
z.object({
rigId: z.string().uuid(),
status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(),
status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(),
})
)
.output(z.array(RpcBeadOutput))
Expand Down
3 changes: 2 additions & 1 deletion cloudflare-gastown/src/trpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const RigOutput = z.object({
export const BeadOutput = z.object({
bead_id: z.string(),
type: z.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']),
status: z.enum(['open', 'in_progress', 'closed', 'failed']),
status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']),
title: z.string(),
body: z.string().nullable(),
rig_id: z.string().nullable(),
Expand Down Expand Up @@ -204,6 +204,7 @@ const AlarmStatusOutput = z.object({
beads: z.object({
open: z.number(),
inProgress: z.number(),
inReview: z.number(),
failed: z.number(),
triageRequests: z.number(),
}),
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AgentMetadataRecord } from './db/tables/agent-metadata.table';

// -- Beads --

export const BeadStatus = z.enum(['open', 'in_progress', 'closed', 'failed']);
export const BeadStatus = z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']);
Comment thread
jrf0110 marked this conversation as resolved.
export type BeadStatus = z.infer<typeof BeadStatus>;

export const BeadType = z.enum([
Expand Down
1 change: 1 addition & 0 deletions cloudflare-gastown/src/ui/dashboard.ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function dashboardHtml(): string {
.badge { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: 11px; }
.badge.open { background: #1f6feb33; color: #58a6ff; }
.badge.in_progress { background: #d29922aa; color: #e3b341; }
.badge.in_review { background: #8957e533; color: #bc8cff; }
.badge.closed { background: #3fb95033; color: #3fb950; }
.badge.idle { background: #21262d; color: #8b949e; }
.badge.working { background: #d29922aa; color: #e3b341; }
Expand Down
9 changes: 8 additions & 1 deletion src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps)
const userBeads = allBeads.filter(b => b.type !== 'agent');
const openBeadCount = userBeads.filter(b => b.status === 'open').length;
const inProgressBeadCount = userBeads.filter(b => b.status === 'in_progress').length;
const inReviewBeadCount = userBeads.filter(b => b.status === 'in_review').length;
const closedBeadCount = userBeads.filter(b => b.status === 'closed').length;
const escalationsCount = events.filter(e => e.event_type === 'escalated').length;

Expand Down Expand Up @@ -221,7 +222,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps)
{/* Left column: activity feed */}
<div className="min-w-0 border-r border-white/[0.06]">
{/* Stats strip */}
<div className="grid grid-cols-4 border-b border-white/[0.06]">
<div className="grid border-b border-white/[0.06]" style={{ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' }}>
<StatCell
label="Open"
value={openBeadCount}
Expand All @@ -234,6 +235,12 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps)
icon={<Bot className="size-3.5" />}
color="text-violet-400"
/>
<StatCell
label="In Review"
value={inReviewBeadCount}
icon={<Eye className="size-3.5" />}
color="text-purple-400"
/>
<StatCell
label="Closed"
value={closedBeadCount}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps)
const inProgressBeads = beads.filter(
b => b.status === 'in_progress' && b.type !== 'agent'
).length;
const inReviewBeads = beads.filter(b => b.status === 'in_review' && b.type !== 'agent').length;
const closedBeads = beads.filter(b => b.status === 'closed' && b.type !== 'agent').length;

return (
Expand Down Expand Up @@ -126,9 +127,10 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps)
</div>

{/* Stats strip */}
<div className="grid grid-cols-3 border-b border-white/[0.06]">
<div className="grid grid-cols-4 border-b border-white/[0.06]">
<RigStatCell label="Open" value={openBeads} color="text-sky-400" />
<RigStatCell label="In Progress" value={inProgressBeads} color="text-amber-400" />
<RigStatCell label="In Review" value={inReviewBeads} color="text-purple-400" />
<RigStatCell label="Closed" value={closedBeads} color="text-emerald-400" />
</div>

Expand Down
Loading
Loading