From 53e9ded585dc1a00d0f920d7280932512ac06735 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 09:50:07 -0500 Subject: [PATCH 1/8] feat(gastown): add in_review bead state between in_progress and closed Beads now transition to in_review when a polecat calls gt_done, rather than going straight to closed. The refinery review then closes the bead on merge or returns it to in_progress for rework. This prevents the bead board from showing beads as done before the refinery has actioned them. Changes: - Add in_review to BeadStatus enum (schema + types) - gt_done transitions source bead to in_review instead of closed - Review failure/conflict returns bead to in_progress and re-dispatches the original polecat for rework - Mayor and polecat prompts updated to reflect the new lifecycle - BeadBoard UI adds In Review column with purple styling - Dashboard badge styling for in_review state Closes #895 --- .../src/db/tables/beads.table.ts | 2 +- cloudflare-gastown/src/dos/Town.do.ts | 26 +++++++++++++++++++ .../src/dos/town/review-queue.ts | 17 +++++++++--- .../src/prompts/mayor-system.prompt.ts | 2 ++ .../src/prompts/polecat-system.prompt.ts | 4 +-- cloudflare-gastown/src/types.ts | 2 +- cloudflare-gastown/src/ui/dashboard.ui.ts | 1 + src/components/gastown/BeadBoard.tsx | 8 +++--- 8 files changed, 51 insertions(+), 11 deletions(-) diff --git a/cloudflare-gastown/src/db/tables/beads.table.ts b/cloudflare-gastown/src/db/tables/beads.table.ts index 9358791614..e629017a1c 100644 --- a/cloudflare-gastown/src/db/tables/beads.table.ts +++ b/cloudflare-gastown/src/db/tables/beads.table.ts @@ -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({ diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 45b00bf674..b7ea5a0534 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -757,6 +757,32 @@ export class TownDO extends DurableObject { 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 the original polecat and re-dispatch + // so the rework starts automatically. + if ((input.status === 'failed' || input.status === 'conflict') && sourceBeadId) { + const sourceBead = beadOps.getBead(this.sql, sourceBeadId); + if (sourceBead?.assignee_agent_bead_id) { + try { + agents.hookBead(this.sql, sourceBead.assignee_agent_bead_id, sourceBeadId); + const hookedAgent = agents.getAgent(this.sql, sourceBead.assignee_agent_bead_id); + if (hookedAgent) { + this.dispatchAgent(hookedAgent, 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 re-hook agent for rework bead=${sourceBeadId}:`, + err + ); + } + } + } } async agentDone(agentId: string, input: AgentDoneInput): Promise { diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index aca88240c2..f595d78c48 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -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); } } @@ -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); } /** diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index 6e360ee67b..db25815191 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -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. diff --git a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts index 508f1de80c..b47f755481 100644 --- a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts @@ -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. @@ -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 diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index 1340d16e95..f721a9463b 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -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']); export type BeadStatus = z.infer; export const BeadType = z.enum([ diff --git a/cloudflare-gastown/src/ui/dashboard.ui.ts b/cloudflare-gastown/src/ui/dashboard.ui.ts index 1fee579ed9..dc414b5da4 100644 --- a/cloudflare-gastown/src/ui/dashboard.ui.ts +++ b/cloudflare-gastown/src/ui/dashboard.ui.ts @@ -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; } diff --git a/src/components/gastown/BeadBoard.tsx b/src/components/gastown/BeadBoard.tsx index fbf3bfa77f..84d32067d4 100644 --- a/src/components/gastown/BeadBoard.tsx +++ b/src/components/gastown/BeadBoard.tsx @@ -30,17 +30,19 @@ type BeadBoardProps = { agentNameById?: Record; }; -const statusColumns = ['open', 'in_progress', 'closed'] as const; +const statusColumns = ['open', 'in_progress', 'in_review', 'closed'] as const; const statusLabels: Record = { open: 'Open', in_progress: 'In Progress', + in_review: 'In Review', closed: 'Closed', }; const statusColors: Record = { open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + in_review: 'bg-purple-500/10 text-purple-400 border-purple-500/20', closed: 'bg-green-500/10 text-green-400 border-green-500/20', }; @@ -160,7 +162,7 @@ export function BeadBoard({ }: BeadBoardProps) { if (isLoading) { return ( -
+
{statusColumns.map(status => (
@@ -175,7 +177,7 @@ export function BeadBoard({ } return ( -
+
{statusColumns.map((status, colIdx) => { const columnBeads = beads.filter(b => b.status === status && b.type !== 'agent'); return ( From a76eae1b415d30ebb6c2dc18798c9e817b7f4beb Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 10:06:21 -0500 Subject: [PATCH 2/8] fix(gastown): add in_review to tRPC bead schemas and rig-beads table The listBeads tRPC route was returning 500 (output validation failed) because beads with in_review status didn't pass the output schema. Add in_review to BeadOutput, the listBeads input filter, the rig-beads table Zod enum + SQL CHECK constraint, and the generated router types. --- .../src/db/tables/rig-beads.table.ts | 4 ++-- cloudflare-gastown/src/trpc/router.ts | 2 +- cloudflare-gastown/src/trpc/schemas.ts | 2 +- src/lib/gastown/types/router.d.ts | 16 ++++++++-------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cloudflare-gastown/src/db/tables/rig-beads.table.ts b/cloudflare-gastown/src/db/tables/rig-beads.table.ts index 327af6d7cb..59c0c01587 100644 --- a/cloudflare-gastown/src/db/tables/rig-beads.table.ts +++ b/cloudflare-gastown/src/db/tables/rig-beads.table.ts @@ -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({ @@ -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`, diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 4fbe8a5909..32df123799 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -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)) diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 3071ee4610..df2a9c0bd3 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -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(), diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index f68aa771ab..15bfe04b23 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -125,7 +125,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; @@ -152,7 +152,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - status?: 'closed' | 'failed' | 'in_progress' | 'open' | undefined; + status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; }; output: { bead_id: string; @@ -164,7 +164,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; @@ -235,7 +235,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; @@ -723,7 +723,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; @@ -750,7 +750,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; - status?: 'closed' | 'failed' | 'in_progress' | 'open' | undefined; + status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; }; output: { bead_id: string; @@ -762,7 +762,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; @@ -833,7 +833,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute | 'merge_request' | 'message' | 'molecule'; - status: 'closed' | 'failed' | 'in_progress' | 'open'; + status: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open'; title: string; body: string | null; rig_id: string | null; From 246c0383e24124f78171d2bbf4324d407dd52126 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 10:33:28 -0500 Subject: [PATCH 3/8] =?UTF-8?q?fix(gastown):=20triage=20handler=20401=20in?= =?UTF-8?q?=20dev=20mode=20=E2=80=94=20fall=20back=20to=20identity=20heade?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In development, authMiddleware is skipped so agentJWT is never set on the Hono context. The triage handler used getEnforcedAgentId() as a hard requirement (returning 401 on null), unlike other handlers which treat it as optional. Fall back to the X-Gastown-Agent-Id header that the container client sends with every request. --- cloudflare-gastown/src/handlers/rig-triage.handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/handlers/rig-triage.handler.ts b/cloudflare-gastown/src/handlers/rig-triage.handler.ts index 50f36558ef..dd8021f775 100644 --- a/cloudflare-gastown/src/handlers/rig-triage.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-triage.handler.ts @@ -29,7 +29,10 @@ const ResolveTriageBody = z.object({ }); export async function handleResolveTriage(c: Context, _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. + const agentId = getEnforcedAgentId(c) || c.req.header('X-Gastown-Agent-Id'); if (!agentId) { return c.json(resError('Agent authentication required'), 401); } From da2427ea425b9a055fe63bf2c95e7fda28f887fa Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 10:58:38 -0500 Subject: [PATCH 4/8] feat(gastown): add triage_resolved events to activity feed and encourage gt_status in triage prompt Triage actions (RESTART, CLOSE_BEAD, ESCALATE, etc.) now emit a triage_resolved bead event on the target bead, making them visible in the activity feed. Previously only a status_changed event was logged on the triage request bead itself, which doesn't appear in the affected bead's timeline. - Add triage_resolved to BeadEventType enum - Log triage_resolved on the target bead in resolveTriage with action and resolution notes in metadata - Add ShieldCheck icon (amber) and description formatting in ActivityFeed - Update triage system prompt to instruct agents to call gt_status at the start and end of their batch for dashboard visibility --- .../src/db/tables/bead-events.table.ts | 1 + cloudflare-gastown/src/dos/Town.do.ts | 18 ++++++++++++++++++ .../src/prompts/triage-system.prompt.ts | 2 ++ src/components/gastown/ActivityFeed.tsx | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index 1ea22b4c07..72c6905544 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -18,6 +18,7 @@ export const BeadEventType = z.enum([ 'pr_created', 'pr_creation_failed', 'agent_status', + 'triage_resolved', ]); export type BeadEventType = z.infer; diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index b7ea5a0534..71d1ce1d6d 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -992,6 +992,24 @@ export class TownDO extends DurableObject { }, }); + // 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 diff --git a/cloudflare-gastown/src/prompts/triage-system.prompt.ts b/cloudflare-gastown/src/prompts/triage-system.prompt.ts index 946c7daea1..dc4feb8611 100644 --- a/cloudflare-gastown/src/prompts/triage-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/triage-system.prompt.ts @@ -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. diff --git a/src/components/gastown/ActivityFeed.tsx b/src/components/gastown/ActivityFeed.tsx index 5dc5cbe140..f198df6554 100644 --- a/src/components/gastown/ActivityFeed.tsx +++ b/src/components/gastown/ActivityFeed.tsx @@ -15,6 +15,7 @@ import { Mail, MessageSquare, ChevronRight, + ShieldCheck, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; @@ -29,6 +30,7 @@ const EVENT_ICONS: Record = { review_completed: GitMerge, mail_sent: Mail, agent_status: MessageSquare, + triage_resolved: ShieldCheck, }; const EVENT_COLORS: Record = { @@ -42,6 +44,7 @@ const EVENT_COLORS: Record = { review_completed: 'text-green-600', mail_sent: 'text-sky-500', agent_status: 'text-white/50', + triage_resolved: 'text-amber-500', }; type TownEvent = GastownOutputs['gastown']['getTownEvents'][number]; @@ -76,6 +79,12 @@ function eventDescription(event: { return `${rigPrefix}Review ${event.new_value ?? 'completed'}`; case 'mail_sent': return `${rigPrefix}Mail sent`; + case 'triage_resolved': { + const action = event.new_value ?? (event.metadata?.action as string | undefined) ?? 'unknown'; + const notes = event.metadata?.resolution_notes as string | undefined; + const desc = `${rigPrefix}Triage: ${action}`; + return notes ? `${desc} — ${notes}` : desc; + } case 'agent_status': { const msg = event.new_value ?? (event.metadata?.message as string | undefined); const agentName = event.metadata?.agent_name as string | undefined; From 5e26f31c1fb7742d52f6b5fc16f5d4e28cdf2941 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 11:16:08 -0500 Subject: [PATCH 5/8] fix(gastown): prevent hooking agents to escalation and triage-request beads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hookBead had no bead-type validation — any agent could be hooked to any bead, including system-managed types like escalation, convoy, agent, and message beads. Triage-request beads (type='issue' with gt:triage-request label) were also unguarded, letting polecats accidentally pick them up. Add two guards in hookBead: - Reject beads with system-managed types (escalation, convoy, agent, message) since no agent should work on these directly - Reject beads with the gt:triage-request label since those are resolved via gt_triage_resolve, not by hooking an agent --- cloudflare-gastown/src/dos/town/agents.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index ad7fcfae92..e5a34166ba 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -220,6 +220,9 @@ 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`); @@ -227,6 +230,22 @@ export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void 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; From 6749b969f8b0d25ff9f4c01167845c856ddd751b Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 13:03:48 -0500 Subject: [PATCH 6/8] =?UTF-8?q?fix(gastown):=20address=20PR=20review=20com?= =?UTF-8?q?ments=20=E2=80=94=20in=5Freview=20gaps,=20rework=20stranding,?= =?UTF-8?q?=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all four review comments and four code review observations: - Wire in_review through container plugin types, mayor gt_list_beads tool, rig detail stats, town overview stats, healthCheck pending count, and getAlarmStatus bead counts - Fix rework stranding: use getOrCreateAgent instead of re-hooking the original polecat (which may already be working on something else) - Fix BeadBoard loading skeleton to use responsive grid-cols-1/sm:4 matching the loaded layout - Add In Review stat cell to both rig detail and town overview pages --- .../container/plugin/mayor-tools.ts | 4 +- cloudflare-gastown/container/plugin/types.ts | 2 +- cloudflare-gastown/src/dos/Town.do.ts | 38 +++++++++++-------- .../[townId]/TownOverviewPageClient.tsx | 9 ++++- .../rigs/[rigId]/RigDetailPageClient.tsx | 4 +- src/components/gastown/BeadBoard.tsx | 2 +- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 6e47735af9..714ba3a02b 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -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 diff --git a/cloudflare-gastown/container/plugin/types.ts b/cloudflare-gastown/container/plugin/types.ts index 7f83b5e132..0fb1998ec3 100644 --- a/cloudflare-gastown/container/plugin/types.ts +++ b/cloudflare-gastown/container/plugin/types.ts @@ -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' diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 71d1ce1d6d..b8ba794f9a 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -759,25 +759,29 @@ export class TownDO extends DurableObject { } // When a review fails or conflicts (rework), the source bead was - // returned to in_progress. Re-hook the original polecat and re-dispatch - // so the rework starts automatically. + // 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) { const sourceBead = beadOps.getBead(this.sql, sourceBeadId); - if (sourceBead?.assignee_agent_bead_id) { + if (sourceBead?.rig_id) { try { - agents.hookBead(this.sql, sourceBead.assignee_agent_bead_id, sourceBeadId); - const hookedAgent = agents.getAgent(this.sql, sourceBead.assignee_agent_bead_id); - if (hookedAgent) { - this.dispatchAgent(hookedAgent, sourceBead).catch(err => - console.error( - `${TOWN_LOG} completeReviewWithResult: fire-and-forget rework dispatch failed for bead=${sourceBeadId}`, - err - ) - ); - } + 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 re-hook agent for rework bead=${sourceBeadId}:`, + `${TOWN_LOG} completeReviewWithResult: could not dispatch rework for bead=${sourceBeadId}:`, err ); } @@ -3289,7 +3293,7 @@ export class TownDO extends DurableObject { [ ...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 @@ -3318,6 +3322,7 @@ export class TownDO extends DurableObject { beads: { open: number; inProgress: number; + inReview: number; failed: number; triageRequests: number; }; @@ -3372,12 +3377,13 @@ export class TownDO extends DurableObject { [] ), ]; - 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; } diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index 226696ab9d..01d28a122f 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -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; @@ -221,7 +222,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) {/* Left column: activity feed */}
{/* Stats strip */} -
+
} color="text-violet-400" /> + } + color="text-purple-400" + /> 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 ( @@ -126,9 +127,10 @@ export function RigDetailPageClient({ townId, rigId }: RigDetailPageClientProps)
{/* Stats strip */} -
+
+
diff --git a/src/components/gastown/BeadBoard.tsx b/src/components/gastown/BeadBoard.tsx index 84d32067d4..eb2316f619 100644 --- a/src/components/gastown/BeadBoard.tsx +++ b/src/components/gastown/BeadBoard.tsx @@ -162,7 +162,7 @@ export function BeadBoard({ }: BeadBoardProps) { if (isLoading) { return ( -
+
{statusColumns.map(status => (
From 3eed70c3e8acb5e9b40e2d1295b0e09da1073805 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 11 Mar 2026 14:21:47 -0500 Subject: [PATCH 7/8] fix(gastown): use inline grid-template-columns for 5-col stats strip Tailwind's grid-cols-5 was being purged or not applying correctly. Use an inline style to guarantee the 5-column layout. --- src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index 01d28a122f..da8b911759 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -222,7 +222,7 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) {/* Left column: activity feed */}
{/* Stats strip */} -
+
Date: Wed, 11 Mar 2026 16:03:36 -0500 Subject: [PATCH 8/8] fix(gastown): gate triage header fallback on dev, wire inReview through alarm status - Gate X-Gastown-Agent-Id header fallback in handleResolveTriage on ENVIRONMENT === 'development' to prevent header spoofing in production - Add inReview to AlarmStatusOutput tRPC schema, router.d.ts types, and TerminalBar UI so the new bead count is visible in the status pane --- .../src/handlers/rig-triage.handler.ts | 7 +++++-- cloudflare-gastown/src/trpc/schemas.ts | 1 + src/components/gastown/TerminalBar.tsx | 13 ++++++++++++- src/lib/gastown/types/router.d.ts | 2 ++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cloudflare-gastown/src/handlers/rig-triage.handler.ts b/cloudflare-gastown/src/handlers/rig-triage.handler.ts index dd8021f775..0041f9bf0e 100644 --- a/cloudflare-gastown/src/handlers/rig-triage.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-triage.handler.ts @@ -31,8 +31,11 @@ const ResolveTriageBody = z.object({ export async function handleResolveTriage(c: Context, _params: { rigId: string }) { // 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. - const agentId = getEnforcedAgentId(c) || c.req.header('X-Gastown-Agent-Id'); + // 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); } diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index df2a9c0bd3..584d54817d 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -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(), }), diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index 6078f0b9e7..8957a22e16 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -148,7 +148,13 @@ export function TerminalBar({ townId }: TerminalBarProps) { type AlarmStatus = { alarm: { nextFireAt: string | null; intervalMs: number; intervalLabel: string }; agents: { working: number; idle: number; stalled: number; dead: number; total: number }; - beads: { open: number; inProgress: number; failed: number; triageRequests: number }; + beads: { + open: number; + inProgress: number; + inReview: number; + failed: number; + triageRequests: number; + }; patrol: { guppWarnings: number; guppEscalations: number; @@ -372,6 +378,11 @@ function AlarmStatusPane({ townId }: { townId: string }) { value={data.beads.inProgress} highlight={data.beads.inProgress > 0} /> + 0} + /> 0} />