Skip to content

Commit 3d251f4

Browse files
authored
feat(gastown): add in_review bead state, fix triage auth and scheduling guards (#1028)
* 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 * 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. * fix(gastown): triage handler 401 in dev mode — fall back to identity header 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. * 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 * fix(gastown): prevent hooking agents to escalation and triage-request beads 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 * fix(gastown): address PR review comments — in_review gaps, rework stranding, responsive layout 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 * 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. * 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
1 parent d168221 commit 3d251f4

22 files changed

Lines changed: 156 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ export function createMayorTools(client: MayorGastownClient) {
7878
gt_list_beads: tool({
7979
description:
8080
'List beads (work items) in a specific rig. ' +
81-
'Optionally filter by status (open, in_progress, closed, failed) or type (issue, message, escalation, merge_request). ' +
81+
'Optionally filter by status (open, in_progress, in_review, closed, failed) or type (issue, message, escalation, merge_request). ' +
8282
'Use this to check what work exists in a rig, what is in progress, and what has been completed.',
8383
args: {
8484
rig_id: tool.schema.string().describe('The UUID of the rig to list beads from'),
8585
status: tool.schema
86-
.enum(['open', 'in_progress', 'closed', 'failed'])
86+
.enum(['open', 'in_progress', 'in_review', 'closed', 'failed'])
8787
.describe('Filter by bead status')
8888
.optional(),
8989
type: tool.schema

cloudflare-gastown/container/plugin/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Types mirroring the Town DO domain model.
22
// These are the API response shapes — the plugin never touches SQLite directly.
33

4-
export type BeadStatus = 'open' | 'in_progress' | 'closed' | 'failed';
4+
export type BeadStatus = 'open' | 'in_progress' | 'in_review' | 'closed' | 'failed';
55
export type BeadType =
66
| 'issue'
77
| 'message'

cloudflare-gastown/src/db/tables/bead-events.table.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const BeadEventType = z.enum([
1818
'pr_created',
1919
'pr_creation_failed',
2020
'agent_status',
21+
'triage_resolved',
2122
]);
2223

2324
export type BeadEventType = z.infer<typeof BeadEventType>;

cloudflare-gastown/src/db/tables/beads.table.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const BeadType = z.enum([
1515
'agent',
1616
]);
1717

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

2121
export const BeadRecord = z.object({

cloudflare-gastown/src/db/tables/rig-beads.table.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from 'zod';
22
import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table';
33

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

88
export const RigBeadRecord = z.object({
@@ -32,7 +32,7 @@ export function createTableRigBeads(): string {
3232
id: `text primary key`,
3333
rig_id: `text`,
3434
type: `text not null check(type in ('issue', 'message', 'escalation', 'merge_request'))`,
35-
status: `text not null default 'open' check(status in ('open', 'in_progress', 'closed', 'failed'))`,
35+
status: `text not null default 'open' check(status in ('open', 'in_progress', 'in_review', 'closed', 'failed'))`,
3636
title: `text not null`,
3737
body: `text`,
3838
assignee_agent_id: `text`,

cloudflare-gastown/src/dos/Town.do.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,36 @@ export class TownDO extends DurableObject<Env> {
757757
if (input.status === 'merged' && sourceBeadId) {
758758
this.dispatchUnblockedBeads(sourceBeadId);
759759
}
760+
761+
// When a review fails or conflicts (rework), the source bead was
762+
// returned to in_progress. Re-hook a polecat and re-dispatch so the
763+
// rework starts automatically. The original polecat may already be
764+
// working on something else, so fall back to getOrCreateAgent.
765+
if ((input.status === 'failed' || input.status === 'conflict') && sourceBeadId) {
766+
const sourceBead = beadOps.getBead(this.sql, sourceBeadId);
767+
if (sourceBead?.rig_id) {
768+
try {
769+
const reworkAgent = agents.getOrCreateAgent(
770+
this.sql,
771+
'polecat',
772+
sourceBead.rig_id,
773+
this.townId
774+
);
775+
agents.hookBead(this.sql, reworkAgent.id, sourceBeadId);
776+
this.dispatchAgent(reworkAgent, sourceBead).catch(err =>
777+
console.error(
778+
`${TOWN_LOG} completeReviewWithResult: fire-and-forget rework dispatch failed for bead=${sourceBeadId}`,
779+
err
780+
)
781+
);
782+
} catch (err) {
783+
console.warn(
784+
`${TOWN_LOG} completeReviewWithResult: could not dispatch rework for bead=${sourceBeadId}:`,
785+
err
786+
);
787+
}
788+
}
789+
}
760790
}
761791

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

999+
// Log a triage_resolved event on the target bead so the action shows
1000+
// up in the activity feed for the bead that was actually affected.
1001+
const targetBeadId = snapshotHookedBeadId ?? targetAgentId;
1002+
if (targetBeadId && targetBeadId !== input.triage_request_bead_id) {
1003+
beadOps.logBeadEvent(this.sql, {
1004+
beadId: targetBeadId,
1005+
agentId: input.agent_id,
1006+
eventType: 'triage_resolved',
1007+
newValue: action,
1008+
metadata: {
1009+
action,
1010+
resolution_notes: input.resolution_notes,
1011+
triage_request_bead_id: input.triage_request_bead_id,
1012+
target_agent_id: targetAgentId,
1013+
},
1014+
});
1015+
}
1016+
9691017
// If this triage request was created for an escalation, close the
9701018
// linked escalation bead too so it doesn't sit open indefinitely.
9711019
// The escalation_bead_id is nested under metadata.context (set by
@@ -3245,7 +3293,7 @@ export class TownDO extends DurableObject<Env> {
32453293
[
32463294
...query(
32473295
this.sql,
3248-
/* sql */ `SELECT COUNT(*) AS cnt FROM ${beads} WHERE ${beads.status} IN ('open', 'in_progress') AND ${beads.type} NOT IN ('agent', 'message')`,
3296+
/* sql */ `SELECT COUNT(*) AS cnt FROM ${beads} WHERE ${beads.status} IN ('open', 'in_progress', 'in_review') AND ${beads.type} NOT IN ('agent', 'message')`,
32493297
[]
32503298
),
32513299
][0]?.cnt ?? 0
@@ -3274,6 +3322,7 @@ export class TownDO extends DurableObject<Env> {
32743322
beads: {
32753323
open: number;
32763324
inProgress: number;
3325+
inReview: number;
32773326
failed: number;
32783327
triageRequests: number;
32793328
};
@@ -3328,12 +3377,13 @@ export class TownDO extends DurableObject<Env> {
33283377
[]
33293378
),
33303379
];
3331-
const beadCounts = { open: 0, inProgress: 0, failed: 0, triageRequests: 0 };
3380+
const beadCounts = { open: 0, inProgress: 0, inReview: 0, failed: 0, triageRequests: 0 };
33323381
for (const row of beadRows) {
33333382
const s = `${row.status as string}`;
33343383
const c = Number(row.cnt);
33353384
if (s === 'open') beadCounts.open = c;
33363385
else if (s === 'in_progress') beadCounts.inProgress = c;
3386+
else if (s === 'in_review') beadCounts.inReview = c;
33373387
else if (s === 'failed') beadCounts.failed = c;
33383388
}
33393389

cloudflare-gastown/src/dos/town/agents.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,32 @@ export function deleteAgent(sql: SqlStorage, agentId: string): void {
220220

221221
// ── Hooks (GUPP) ────────────────────────────────────────────────────
222222

223+
/** Bead types that are system-managed and should never be hooked to an agent. */
224+
const UNHOOKABLE_BEAD_TYPES = new Set(['escalation', 'convoy', 'agent', 'message']);
225+
223226
export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void {
224227
const agent = getAgent(sql, agentId);
225228
if (!agent) throw new Error(`Agent ${agentId} not found`);
226229

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

233+
// Prevent hooking to system-managed bead types that no agent should
234+
// work on directly. Escalation beads are resolved by triage, convoy
235+
// beads are containers, agent/message beads are metadata records.
236+
if (UNHOOKABLE_BEAD_TYPES.has(bead.type)) {
237+
throw new Error(`Cannot hook agent to bead ${beadId}: type '${bead.type}' is not workable`);
238+
}
239+
240+
// Triage request beads are resolved by the triage agent via
241+
// gt_triage_resolve, not by hooking. Prevent polecats from
242+
// accidentally picking these up.
243+
if (bead.labels.includes('gt:triage-request')) {
244+
throw new Error(
245+
`Cannot hook agent to bead ${beadId}: triage requests are resolved via gt_triage_resolve`
246+
);
247+
}
248+
230249
// Already hooked to this bead — idempotent
231250
if (agent.current_hook_bead_id === beadId) return;
232251

cloudflare-gastown/src/dos/town/review-queue.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,13 @@ export function completeReviewWithResult(
313313
conflict: true,
314314
},
315315
});
316+
// Return source bead to in_progress so the polecat can be re-dispatched
317+
// to resolve the conflict (in_review → in_progress rework flow).
318+
updateBeadStatus(sql, entry.bead_id, 'in_progress', entry.agent_id);
319+
} else if (input.status === 'failed') {
320+
// Review failed (rework requested): return source bead to in_progress
321+
// so it can be re-dispatched (in_review → in_progress rework flow).
322+
updateBeadStatus(sql, entry.bead_id, 'in_progress', entry.agent_id);
316323
}
317324
}
318325

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

559-
// Close the source bead (matches upstream gt done behavior). The polecat's
560-
// work is done — the MR bead now tracks the merge lifecycle. The source
561-
// bead retains its assignee so we know which agent worked on it.
566+
// Transition the source bead to in_review — the polecat's work is done
567+
// but the refinery hasn't reviewed it yet. The MR bead tracks the merge
568+
// lifecycle. The source bead retains its assignee so we know which agent
569+
// worked on it. It will be closed (or returned to in_progress) by the
570+
// refinery after review.
562571
unhookBead(sql, agentId);
563-
closeBead(sql, sourceBead, agentId);
572+
updateBeadStatus(sql, sourceBead, 'in_review', agentId);
564573
}
565574

566575
/**

cloudflare-gastown/src/handlers/rig-triage.handler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ const ResolveTriageBody = z.object({
2929
});
3030

3131
export async function handleResolveTriage(c: Context<GastownEnv>, _params: { rigId: string }) {
32-
const agentId = getEnforcedAgentId(c);
32+
// In production, agentId comes from the verified JWT. In development
33+
// (where authMiddleware is skipped), fall back to the identity header
34+
// the container client sends with every request. The fallback is gated
35+
// on ENVIRONMENT to prevent header spoofing in production.
36+
const agentId =
37+
getEnforcedAgentId(c) ||
38+
(c.env.ENVIRONMENT === 'development' ? c.req.header('X-Gastown-Agent-Id') : null);
3339
if (!agentId) {
3440
return c.json(resError('Agent authentication required'), 401);
3541
}

cloudflare-gastown/src/prompts/mayor-system.prompt.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ When a user asks "how's X going?" or wants a progress update:
137137
138138
Convoys land automatically when all tracked beads close — no manual management needed.
139139
140+
Bead lifecycle: \`open\` → \`in_progress\` (polecat working) → \`in_review\` (gt_done called, awaiting refinery) → \`closed\` (merged) or back to \`in_progress\` (rework requested).
141+
140142
## Conversational Model
141143
142144
- **Respond directly for questions.** If the user asks a question you can answer from context, respond conversationally. Don't delegate questions.

0 commit comments

Comments
 (0)