From 3c0a23f536d23b1f3c67453e2002321f9cc6fe17 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 00:29:03 -0500 Subject: [PATCH 1/8] fix(gastown): restore agent working status on heartbeat after dispatch timeout race (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding fixes for the 5-minute bead reset cycle caused by a timing race between startAgentInContainer's 60s timeout and slow cold starts: 1. touchAgent restores idle→working on heartbeat — a heartbeat is proof the agent is alive in the container regardless of its recorded status. 2. reconcileBeads Rule 3 checks last_activity_at freshness — defense in depth so an agent with a recent heartbeat is never treated as lost, even if its status field is wrong. 3. dispatchAgent !started path no longer sets agent to idle — leaves it working so the reconciler doesn't reset the bead. reconcileAgents catches truly dead agents after 90s of missing heartbeats. Closes #1358 --- cloudflare-gastown/src/dos/town/agents.ts | 9 +++ cloudflare-gastown/src/dos/town/reconciler.ts | 20 ++++-- cloudflare-gastown/src/dos/town/scheduling.ts | 23 +++--- .../test/integration/reconciler.test.ts | 71 +++++++++++++++++++ 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index 62d68acdc..c208a3834 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -546,11 +546,20 @@ export function touchAgent( activeTools?: string[]; } ): void { + // A heartbeat is proof the agent is alive in the container. + // If the agent's status is 'idle' (e.g. due to a dispatch timeout + // race — see #1358), restore it to 'working'. This prevents the + // reconciler from treating the agent as lost while it's actively + // sending heartbeats. query( sql, /* sql */ ` UPDATE ${agent_metadata} SET ${agent_metadata.columns.last_activity_at} = ?, + ${agent_metadata.columns.status} = CASE + WHEN ${agent_metadata.columns.status} = 'idle' THEN 'working' + ELSE ${agent_metadata.columns.status} + END, ${agent_metadata.columns.last_event_type} = COALESCE(?, ${agent_metadata.columns.last_event_type}), ${agent_metadata.columns.last_event_at} = COALESCE(?, ${agent_metadata.columns.last_event_at}), ${agent_metadata.columns.active_tools} = COALESCE(?, ${agent_metadata.columns.active_tools}) diff --git a/cloudflare-gastown/src/dos/town/reconciler.ts b/cloudflare-gastown/src/dos/town/reconciler.ts index fcaae03e4..5b39849ba 100644 --- a/cloudflare-gastown/src/dos/town/reconciler.ts +++ b/cloudflare-gastown/src/dos/town/reconciler.ts @@ -599,18 +599,24 @@ export function reconcileBeads(sql: SqlStorage): Action[] { for (const bead of staleInProgress) { if (!staleMs(bead.updated_at, STALE_IN_PROGRESS_TIMEOUT_MS)) continue; - // Check if any agent is hooked AND working/stalled + // Check if any agent is hooked AND (working/stalled OR has a recent + // heartbeat). The heartbeat check is defense-in-depth for #1358: if + // the agent's status is wrong (e.g. stuck on 'idle' due to a dispatch + // timeout race), a fresh heartbeat proves the agent is alive. const hookedAgent = z - .object({ status: z.string() }) + .object({ status: z.string(), last_activity_at: z.string().nullable() }) .array() .parse([ ...query( sql, /* sql */ ` - SELECT ${agent_metadata.status} + SELECT ${agent_metadata.status}, ${agent_metadata.last_activity_at} FROM ${agent_metadata} WHERE ${agent_metadata.current_hook_bead_id} = ? - AND ${agent_metadata.status} IN ('working', 'stalled') + AND ( + ${agent_metadata.status} IN ('working', 'stalled') + OR ${agent_metadata.last_activity_at} > datetime('now', '-90 seconds') + ) `, [bead.bead_id] ), @@ -991,7 +997,11 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { } const mrRows = z - .object({ status: z.string(), type: z.string(), rig_id: z.string().nullable() }) + .object({ + status: z.string(), + type: z.string(), + rig_id: z.string().nullable(), + }) .array() .parse([ ...query( diff --git a/cloudflare-gastown/src/dos/town/scheduling.ts b/cloudflare-gastown/src/dos/town/scheduling.ts index 0fb9544ad..e18d14eff 100644 --- a/cloudflare-gastown/src/dos/town/scheduling.ts +++ b/cloudflare-gastown/src/dos/town/scheduling.ts @@ -159,20 +159,11 @@ export async function dispatchAgent( }); } else { // Container start returned false — but the container may have - // actually started the agent (timeout race). DON'T roll back - // the bead to open. Leave it in_progress with the agent idle+hooked. - // If the agent truly failed: rehookOrphanedBeads recovers after 2 min. - // If the agent actually started: it works and calls gt_done normally. - query( - ctx.sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.status} = 'idle', - ${agent_metadata.columns.last_activity_at} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [now(), agent.id] - ); + // actually started the agent (timeout race). Leave the agent + // as 'working' so the reconciler doesn't treat it as lost. + // If the agent truly didn't start: reconcileAgents catches it + // after 90s of missing heartbeats and transitions to 'idle'. + // If the agent actually started: heartbeats keep it alive. (#1358) ctx.emitEvent({ event: 'agent.dispatch_failed', townId: ctx.townId, @@ -185,7 +176,9 @@ export async function dispatchAgent( return started; } catch (err) { console.error(`${LOG} dispatchAgent: failed for agent=${agent.id}:`, err); - Sentry.captureException(err, { extra: { agentId: agent.id, beadId: bead.bead_id } }); + Sentry.captureException(err, { + extra: { agentId: agent.id, beadId: bead.bead_id }, + }); try { query( ctx.sql, diff --git a/cloudflare-gastown/test/integration/reconciler.test.ts b/cloudflare-gastown/test/integration/reconciler.test.ts index b7373b345..e5e286924 100644 --- a/cloudflare-gastown/test/integration/reconciler.test.ts +++ b/cloudflare-gastown/test/integration/reconciler.test.ts @@ -126,6 +126,77 @@ describe('Reconciler', () => { }); }); + // ── #1358: Heartbeat restores working status ──────────────────────── + + describe('#1358: dispatch timeout race recovery', () => { + it('should restore idle agent to working on heartbeat', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P1', + identity: `heartbeat-restore-${townName}`, + rig_id: 'rig-1', + }); + const bead = await town.createBead({ + type: 'issue', + title: 'Heartbeat test', + rig_id: 'rig-1', + }); + + // Simulate dispatch timeout race: agent is hooked + idle + // (dispatchAgent set it to working, then timeout set it back to idle) + await town.hookBead(agent.id, bead.bead_id); + await town.updateAgentStatus(agent.id, 'idle'); + + const before = await town.getAgentAsync(agent.id); + expect(before?.status).toBe('idle'); + + // Agent sends a heartbeat (proving it's alive in the container) + await town.touchAgentHeartbeat(agent.id); + + // Status should be restored to working + const after = await town.getAgentAsync(agent.id); + expect(after?.status).toBe('working'); + }); + + it('should not change status of a working agent on heartbeat', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P2', + identity: `heartbeat-noop-${townName}`, + rig_id: 'rig-1', + }); + const bead = await town.createBead({ + type: 'issue', + title: 'Heartbeat noop test', + rig_id: 'rig-1', + }); + + await town.hookBead(agent.id, bead.bead_id); + await town.updateAgentStatus(agent.id, 'working'); + + await town.touchAgentHeartbeat(agent.id); + + const after = await town.getAgentAsync(agent.id); + expect(after?.status).toBe('working'); + }); + + it('should not change status of an exited agent on heartbeat', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P3', + identity: `heartbeat-exited-${townName}`, + rig_id: 'rig-1', + }); + + await town.updateAgentStatus(agent.id, 'exited'); + + await town.touchAgentHeartbeat(agent.id); + + const after = await town.getAgentAsync(agent.id); + expect(after?.status).toBe('exited'); + }); + }); + // ── Event-driven agentDone ────────────────────────────────────────── describe('event-driven agentDone', () => { From 0b5643926a84d7e527f1ca80e961959900781d1a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 02:04:47 -0500 Subject: [PATCH 2/8] fix(gastown): add cold start grace period for container_status not_found The container status pre-phase polls /agents/:id/status on every alarm tick. During a cold start (git clone + worktree), the agent hasn't registered in the process manager yet, so the container returns 404. This was immediately setting the agent to idle, undoing the dispatch timeout fix. Add a 3-minute grace period for not_found status: if the agent was dispatched recently (last_activity_at < 3 min ago), ignore the 404. Truly dead agents are still caught by reconcileAgents after 90s of missing heartbeats. --- cloudflare-gastown/src/dos/town/reconciler.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/dos/town/reconciler.ts b/cloudflare-gastown/src/dos/town/reconciler.ts index 5b39849ba..d781f5bc8 100644 --- a/cloudflare-gastown/src/dos/town/reconciler.ts +++ b/cloudflare-gastown/src/dos/town/reconciler.ts @@ -249,7 +249,18 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { const agent = agents.getAgent(sql, event.agent_id); if (!agent) return; - // Only act on working/stalled agents whose container has stopped + // Only act on working/stalled agents whose container has stopped. + // For 'not_found': skip if the agent was dispatched recently (#1358). + // During a cold start the container may 404 on /agents/:id/status + // because the agent hasn't registered in the process manager yet. + // The 3-minute grace period covers the 60s HTTP timeout plus + // typical cold start time (git clone + worktree). Truly dead + // agents are caught by reconcileAgents after 90s of no heartbeats. + if (containerStatus === 'not_found' && agent.last_activity_at) { + const ageSec = (Date.now() - new Date(agent.last_activity_at).getTime()) / 1000; + if (ageSec < 180) return; // 3-minute grace for cold starts + } + if ( (agent.status === 'working' || agent.status === 'stalled') && (containerStatus === 'exited' || containerStatus === 'not_found') From 3a4dcbcae1c01c6c928c8e4ffa68062d1634e554 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 08:56:33 -0500 Subject: [PATCH 3/8] fix(gastown): fix SQLite datetime comparison bug that prevented stuck bead recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileBeads Rule 3 compared ISO 8601 timestamps (2026-03-21T05:55:50Z) against SQLite datetime() output (2026-03-21 05:55:50). Since 'T' (ASCII 84) > ' ' (ASCII 32), the comparison last_activity_at > datetime('now', '-90 seconds') was ALWAYS TRUE — the heartbeat check never expired. Rule 3 thought every hooked agent had a fresh heartbeat and never recovered stuck in_progress beads. Fix: use strftime('%Y-%m-%dT%H:%M:%fZ', ...) to produce ISO 8601 format matching the stored timestamps. Also: move invariant violation logging from console.error (spamming Workers logs every 5s per town) to analytics events for observability dashboards. Closes #1361 --- cloudflare-gastown/src/dos/Town.do.ts | 1502 ++++++++++------- cloudflare-gastown/src/dos/town/reconciler.ts | 453 ++--- 2 files changed, 1158 insertions(+), 797 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a..5a2dc36c9 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -13,26 +13,29 @@ * AgentDOs to stay within the 10GB DO SQLite limit. */ -import { DurableObject } from 'cloudflare:workers'; -import * as Sentry from '@sentry/cloudflare'; -import { z } from 'zod'; +import { DurableObject } from "cloudflare:workers"; +import * as Sentry from "@sentry/cloudflare"; +import { z } from "zod"; // Sub-modules (plain functions, not classes — per coding style) -import * as beadOps from './town/beads'; -import * as agents from './town/agents'; -import * as mail from './town/mail'; -import * as reviewQueue from './town/review-queue'; -import * as config from './town/config'; -import * as rigs from './town/rigs'; -import * as dispatch from './town/container-dispatch'; -import * as patrol from './town/patrol'; -import * as scheduling from './town/scheduling'; -import * as events from './town/events'; -import * as reconciler from './town/reconciler'; -import { applyAction } from './town/actions'; -import type { ApplyActionContext } from './town/actions'; -import { buildRefinerySystemPrompt } from '../prompts/refinery-system.prompt'; -import { GitHubPRStatusSchema, GitLabMRStatusSchema } from '../util/platform-pr.util'; +import * as beadOps from "./town/beads"; +import * as agents from "./town/agents"; +import * as mail from "./town/mail"; +import * as reviewQueue from "./town/review-queue"; +import * as config from "./town/config"; +import * as rigs from "./town/rigs"; +import * as dispatch from "./town/container-dispatch"; +import * as patrol from "./town/patrol"; +import * as scheduling from "./town/scheduling"; +import * as events from "./town/events"; +import * as reconciler from "./town/reconciler"; +import { applyAction } from "./town/actions"; +import type { ApplyActionContext } from "./town/actions"; +import { buildRefinerySystemPrompt } from "../prompts/refinery-system.prompt"; +import { + GitHubPRStatusSchema, + GitLabMRStatusSchema, +} from "../util/platform-pr.util"; // Table imports for beads-centric operations import { @@ -40,24 +43,27 @@ import { BeadRecord, EscalationBeadRecord, ConvoyBeadRecord, -} from '../db/tables/beads.table'; -import { agent_metadata, AgentMetadataRecord } from '../db/tables/agent-metadata.table'; -import { review_metadata } from '../db/tables/review-metadata.table'; -import { escalation_metadata } from '../db/tables/escalation-metadata.table'; -import { convoy_metadata } from '../db/tables/convoy-metadata.table'; -import { bead_dependencies } from '../db/tables/bead-dependencies.table'; +} from "../db/tables/beads.table"; +import { + agent_metadata, + AgentMetadataRecord, +} from "../db/tables/agent-metadata.table"; +import { review_metadata } from "../db/tables/review-metadata.table"; +import { escalation_metadata } from "../db/tables/escalation-metadata.table"; +import { convoy_metadata } from "../db/tables/convoy-metadata.table"; +import { bead_dependencies } from "../db/tables/bead-dependencies.table"; import { agent_nudges, AgentNudgeRecord, createTableAgentNudges, getIndexesAgentNudges, -} from '../db/tables/agent-nudges.table'; -import { query } from '../util/query.util'; -import { getAgentDOStub } from './Agent.do'; -import { getTownContainerStub } from './TownContainer.do'; +} from "../db/tables/agent-nudges.table"; +import { query } from "../util/query.util"; +import { getAgentDOStub } from "./Agent.do"; +import { getTownContainerStub } from "./TownContainer.do"; -import { writeEvent, type GastownEventData } from '../util/analytics.util'; -import { BeadPriority } from '../types'; +import { writeEvent, type GastownEventData } from "../util/analytics.util"; +import { BeadPriority } from "../types"; import type { TownConfig, TownConfigUpdate, @@ -81,37 +87,41 @@ import type { MergeStrategy, ConvoyMergeMode, UiAction, -} from '../types'; +} from "../types"; -const TOWN_LOG = '[Town.do]'; +const TOWN_LOG = "[Town.do]"; /** Format a bead_events row into a human-readable message for the status feed. */ function formatEventMessage(row: Record): string { - const s = (v: unknown) => (v == null ? '' : `${v as string}`); + const s = (v: unknown) => (v == null ? "" : `${v as string}`); const eventType = s(row.event_type); const beadTitle = row.bead_title ? s(row.bead_title) : null; const newValue = row.new_value ? s(row.new_value) : null; const agentId = row.agent_id ? s(row.agent_id).slice(0, 8) : null; const beadId = row.bead_id ? s(row.bead_id).slice(0, 8) : null; - const target = beadTitle ? `"${beadTitle}"` : beadId ? `bead ${beadId}…` : 'unknown'; - const actor = agentId ? `agent ${agentId}…` : 'system'; + const target = beadTitle + ? `"${beadTitle}"` + : beadId + ? `bead ${beadId}…` + : "unknown"; + const actor = agentId ? `agent ${agentId}…` : "system"; switch (eventType) { - case 'status_changed': - return `${target} → ${newValue ?? '?'} (by ${actor})`; - case 'assigned': + case "status_changed": + return `${target} → ${newValue ?? "?"} (by ${actor})`; + case "assigned": return `${target} assigned to ${actor}`; - case 'pr_created': + case "pr_created": return `PR created for ${target}`; - case 'pr_merged': + case "pr_merged": return `PR merged for ${target}`; - case 'pr_creation_failed': + case "pr_creation_failed": return `PR creation failed for ${target}`; - case 'escalation_created': + case "escalation_created": return `Escalation created: ${target}`; - case 'agent_status': - return `${actor}: ${newValue ?? 'status update'}`; + case "agent_status": + return `${actor}: ${newValue ?? "status update"}`; default: return `${eventType}: ${target}`; } @@ -124,7 +134,7 @@ const IDLE_ALARM_INTERVAL_MS = 1 * 60_000; // 1m when idle // Escalation constants const STALE_ESCALATION_THRESHOLD_MS = 4 * 60 * 60 * 1000; const MAX_RE_ESCALATIONS = 3; -const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'] as const; +const SEVERITY_ORDER = ["low", "medium", "high", "critical"] as const; function generateId(): string { return crypto.randomUUID(); @@ -152,7 +162,7 @@ type EscalationEntry = { id: string; source_rig_id: string; source_agent_id: string | null; - severity: 'low' | 'medium' | 'high' | 'critical'; + severity: "low" | "medium" | "high" | "critical"; category: string | null; message: string; acknowledged: number; @@ -164,7 +174,7 @@ type EscalationEntry = { function toEscalation(row: EscalationBeadRecord): EscalationEntry { return { id: row.bead_id, - source_rig_id: row.rig_id ?? '', + source_rig_id: row.rig_id ?? "", source_agent_id: row.created_by, severity: row.severity, category: row.category, @@ -180,7 +190,7 @@ function toEscalation(row: EscalationBeadRecord): EscalationEntry { type ConvoyEntry = { id: string; title: string; - status: 'active' | 'landed'; + status: "active" | "landed"; staged: boolean; total_beads: number; closed_beads: number; @@ -195,7 +205,7 @@ function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { return { id: row.bead_id, title: row.title, - status: row.status === 'closed' ? 'landed' : 'active', + status: row.status === "closed" ? "landed" : "active", staged: row.staged === 1, total_beads: row.total_beads, closed_beads: row.closed_beads, @@ -239,8 +249,12 @@ export class TownDO extends DurableObject { }); } - private emitEvent(data: Omit): void { - writeEvent(this.env, { ...data, delivery: 'internal', userId: this._ownerUserId }); + private emitEvent(data: Omit): void { + writeEvent(this.env, { + ...data, + delivery: "internal", + userId: this._ownerUserId, + }); } /** Build the context object used by the scheduling sub-module. */ @@ -253,7 +267,7 @@ export class TownDO extends DurableObject { getTownConfig: () => this.getTownConfig(), getRigConfig: (rigId: string) => this.getRigConfig(rigId), resolveKilocodeToken: () => this.resolveKilocodeToken(), - emitEvent: data => this.emitEvent(data), + emitEvent: (data) => this.emitEvent(data), }; } @@ -270,47 +284,55 @@ export class TownDO extends DurableObject { // Build refinery-specific system prompt with branch/target info let systemPromptOverride: string | undefined; - if (agent.role === 'refinery' && bead.type === 'merge_request') { + if (agent.role === "refinery" && bead.type === "merge_request") { const reviewMeta = reviewQueue.getReviewMetadata(this.sql, beadId); const sourceBeadId = - typeof bead.metadata?.source_bead_id === 'string' ? bead.metadata.source_bead_id : null; + typeof bead.metadata?.source_bead_id === "string" + ? bead.metadata.source_bead_id + : null; const townConfig = await this.getTownConfig(); systemPromptOverride = buildRefinerySystemPrompt({ identity: agent.identity, rigId, townId: this.townId, gates: townConfig.refinery?.gates ?? [], - branch: reviewMeta?.branch ?? 'unknown', - targetBranch: reviewMeta?.target_branch ?? 'main', + branch: reviewMeta?.branch ?? "unknown", + targetBranch: reviewMeta?.target_branch ?? "main", polecatAgentId: - typeof bead.metadata?.source_agent_id === 'string' + typeof bead.metadata?.source_agent_id === "string" ? bead.metadata.source_agent_id - : 'unknown', - mergeStrategy: townConfig.merge_strategy ?? 'direct', + : "unknown", + mergeStrategy: townConfig.merge_strategy ?? "direct", }); } - return scheduling.dispatchAgent(schedulingCtx, agent, bead, { systemPromptOverride }); + return scheduling.dispatchAgent(schedulingCtx, agent, bead, { + systemPromptOverride, + }); }, - stopAgent: async agentId => { + stopAgent: async (agentId) => { await dispatch.stopAgentInContainer(this.env, this.townId, agentId); }, - checkPRStatus: async prUrl => { + checkPRStatus: async (prUrl) => { const townConfig = await this.getTownConfig(); return this.checkPRStatus(prUrl, townConfig); }, queueNudge: async (agentId, message, _tier) => { await this.queueNudge(agentId, message, { - mode: 'immediate', - priority: 'urgent', - source: 'reconciler', + mode: "immediate", + priority: "urgent", + source: "reconciler", }); }, insertEvent: (eventType, params) => { - events.insertEvent(this.sql, eventType as Parameters[1], params); + events.insertEvent( + this.sql, + eventType as Parameters[1], + params, + ); }, - emitEvent: data => { - if (typeof data.event === 'string') { + emitEvent: (data) => { + if (typeof data.event === "string") { this.emitEvent(data as Parameters[0]); } }, @@ -326,12 +348,12 @@ export class TownDO extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); if ( - url.pathname.endsWith('/status/ws') && - request.headers.get('Upgrade')?.toLowerCase() === 'websocket' + url.pathname.endsWith("/status/ws") && + request.headers.get("Upgrade")?.toLowerCase() === "websocket" ) { const pair = new WebSocketPair(); const [client, server] = [pair[0], pair[1]]; - this.ctx.acceptWebSocket(server, ['status']); + this.ctx.acceptWebSocket(server, ["status"]); // Send an initial snapshot immediately so the client doesn't // wait for the next alarm tick. @@ -345,11 +367,14 @@ export class TownDO extends DurableObject { return new Response(null, { status: 101, webSocket: client }); } - return new Response('Not found', { status: 404 }); + return new Response("Not found", { status: 404 }); } /** Called by the runtime when a hibernated WebSocket receives a message. */ - async webSocketMessage(_ws: WebSocket, _message: string | ArrayBuffer): Promise { + async webSocketMessage( + _ws: WebSocket, + _message: string | ArrayBuffer, + ): Promise { // Status WebSocket is server-push only — ignore client messages. } @@ -358,7 +383,7 @@ export class TownDO extends DurableObject { ws: WebSocket, _code: number, _reason: string, - _wasClean: boolean + _wasClean: boolean, ): Promise { try { ws.close(); @@ -370,7 +395,7 @@ export class TownDO extends DurableObject { /** Called by the runtime when a hibernated WebSocket errors. */ async webSocketError(ws: WebSocket, _error: unknown): Promise { try { - ws.close(1011, 'WebSocket error'); + ws.close(1011, "WebSocket error"); } catch { // Already closed } @@ -380,8 +405,10 @@ export class TownDO extends DurableObject { * Broadcast the alarm status snapshot to all connected status WebSocket * clients. Called at the end of each alarm tick. */ - private broadcastAlarmStatus(snapshot: Awaited>): void { - const sockets = this.ctx.getWebSockets('status'); + private broadcastAlarmStatus( + snapshot: Awaited>, + ): void { + const sockets = this.ctx.getWebSockets("status"); if (sockets.length === 0) return; const payload = JSON.stringify(snapshot); @@ -399,11 +426,11 @@ export class TownDO extends DurableObject { * WebSocket clients. Called whenever an agent updates its status message. */ private broadcastAgentStatus(agentId: string, message: string): void { - const sockets = this.ctx.getWebSockets('status'); + const sockets = this.ctx.getWebSockets("status"); if (sockets.length === 0) return; const payload = JSON.stringify({ - type: 'agent_status', + type: "agent_status", agentId, message, timestamp: now(), @@ -422,16 +449,20 @@ export class TownDO extends DurableObject { * WebSocket clients. Called after bead create/update/close operations. */ private broadcastBeadEvent(event: { - type: 'bead.created' | 'bead.status_changed' | 'bead.closed' | 'bead.failed'; + type: + | "bead.created" + | "bead.status_changed" + | "bead.closed" + | "bead.failed"; beadId: string; title?: string; status?: string; rigId?: string; convoyId?: string; }): void { - const sockets = this.ctx.getWebSockets('status'); + const sockets = this.ctx.getWebSockets("status"); if (sockets.length === 0) return; - const frame = JSON.stringify({ channel: 'bead', ...event, ts: now() }); + const frame = JSON.stringify({ channel: "bead", ...event, ts: now() }); for (const ws of sockets) { try { ws.send(frame); @@ -445,11 +476,15 @@ export class TownDO extends DurableObject { * Broadcast convoy progress to all connected status WebSocket clients. * Called from onBeadClosed() after updating closed_beads count. */ - private broadcastConvoyProgress(convoyId: string, totalBeads: number, closedBeads: number): void { - const sockets = this.ctx.getWebSockets('status'); + private broadcastConvoyProgress( + convoyId: string, + totalBeads: number, + closedBeads: number, + ): void { + const sockets = this.ctx.getWebSockets("status"); if (sockets.length === 0) return; const frame = JSON.stringify({ - channel: 'convoy', + channel: "convoy", convoyId, totalBeads, closedBeads, @@ -469,9 +504,9 @@ export class TownDO extends DurableObject { * Called by the mayor via the /mayor/ui-action HTTP route. */ async broadcastUiAction(action: UiAction): Promise { - const sockets = this.ctx.getWebSockets('status'); + const sockets = this.ctx.getWebSockets("status"); if (sockets.length === 0) return; - const frame = JSON.stringify({ channel: 'ui_action', action, ts: now() }); + const frame = JSON.stringify({ channel: "ui_action", action, ts: now() }); for (const ws of sockets) { try { ws.send(frame); @@ -492,7 +527,7 @@ export class TownDO extends DurableObject { private async initializeDatabase(): Promise { // Load persisted town ID if available - const storedId = await this.ctx.storage.get('town:id'); + const storedId = await this.ctx.storage.get("town:id"); if (storedId) this._townId = storedId; // Cache owner_user_id for analytics events @@ -544,7 +579,7 @@ export class TownDO extends DurableObject { */ async setTownId(townId: string): Promise { this._townId = townId; - await this.ctx.storage.put('town:id', townId); + await this.ctx.storage.put("town:id", townId); } async setDashboardContext(context: string): Promise { @@ -583,7 +618,7 @@ export class TownDO extends DurableObject { */ async forceRefreshContainerToken(): Promise { const townId = this.townId; - if (!townId) throw new Error('townId not set'); + if (!townId) throw new Error("townId not set"); const townConfig = await this.getTownConfig(); const userId = townConfig.owner_user_id ?? townId; await dispatch.forceRefreshContainerToken(this.env, townId, userId); @@ -604,13 +639,16 @@ export class TownDO extends DurableObject { // Map config fields to their container env var equivalents. // When a value is set, push it; when cleared, remove it. const envMapping: Array<[string, string | undefined]> = [ - ['GIT_TOKEN', townConfig.git_auth?.github_token], - ['GITLAB_TOKEN', townConfig.git_auth?.gitlab_token], - ['GITLAB_INSTANCE_URL', townConfig.git_auth?.gitlab_instance_url], - ['GITHUB_CLI_PAT', townConfig.github_cli_pat], - ['GASTOWN_GIT_AUTHOR_NAME', townConfig.git_author_name], - ['GASTOWN_GIT_AUTHOR_EMAIL', townConfig.git_author_email], - ['GASTOWN_DISABLE_AI_COAUTHOR', townConfig.disable_ai_coauthor ? '1' : undefined], + ["GIT_TOKEN", townConfig.git_auth?.github_token], + ["GITLAB_TOKEN", townConfig.git_auth?.gitlab_token], + ["GITLAB_INSTANCE_URL", townConfig.git_auth?.gitlab_instance_url], + ["GITHUB_CLI_PAT", townConfig.github_cli_pat], + ["GASTOWN_GIT_AUTHOR_NAME", townConfig.git_author_name], + ["GASTOWN_GIT_AUTHOR_EMAIL", townConfig.git_author_email], + [ + "GASTOWN_DISABLE_AI_COAUTHOR", + townConfig.disable_ai_coauthor ? "1" : undefined, + ], ]; for (const [key, value] of envMapping) { @@ -621,7 +659,10 @@ export class TownDO extends DurableObject { await container.deleteEnvVar(key); } } catch (err) { - console.warn(`[Town.do] syncConfigToContainer: ${key} sync failed:`, err); + console.warn( + `[Town.do] syncConfigToContainer: ${key} sync failed:`, + err, + ); } } } @@ -649,7 +690,7 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ?`, - [rigId] + [rigId], ), ]); for (const { bead_id } of rigBeads) { @@ -669,26 +710,39 @@ export class TownDO extends DurableObject { async configureRig(rigConfig: RigConfig): Promise { console.log( - `${TOWN_LOG} configureRig: rigId=${rigConfig.rigId} hasKilocodeToken=${!!rigConfig.kilocodeToken}` + `${TOWN_LOG} configureRig: rigId=${rigConfig.rigId} hasKilocodeToken=${!!rigConfig.kilocodeToken}`, ); await this.ctx.storage.put(`rig:${rigConfig.rigId}:config`, rigConfig); if (rigConfig.kilocodeToken) { const townConfig = await this.getTownConfig(); - if (!townConfig.kilocode_token || townConfig.kilocode_token !== rigConfig.kilocodeToken) { - console.log(`${TOWN_LOG} configureRig: propagating kilocodeToken to town config`); - await this.updateTownConfig({ kilocode_token: rigConfig.kilocodeToken }); + if ( + !townConfig.kilocode_token || + townConfig.kilocode_token !== rigConfig.kilocodeToken + ) { + console.log( + `${TOWN_LOG} configureRig: propagating kilocodeToken to town config`, + ); + await this.updateTownConfig({ + kilocode_token: rigConfig.kilocodeToken, + }); } } - const token = rigConfig.kilocodeToken ?? (await this.resolveKilocodeToken()); + const token = + rigConfig.kilocodeToken ?? (await this.resolveKilocodeToken()); if (token) { try { const container = getTownContainerStub(this.env, this.townId); - await container.setEnvVar('KILOCODE_TOKEN', token); - console.log(`${TOWN_LOG} configureRig: stored KILOCODE_TOKEN on TownContainerDO`); + await container.setEnvVar("KILOCODE_TOKEN", token); + console.log( + `${TOWN_LOG} configureRig: stored KILOCODE_TOKEN on TownContainerDO`, + ); } catch (err) { - console.warn(`${TOWN_LOG} configureRig: failed to store token on container DO:`, err); + console.warn( + `${TOWN_LOG} configureRig: failed to store token on container DO:`, + err, + ); } } @@ -696,7 +750,7 @@ export class TownDO extends DurableObject { await this.armAlarmIfNeeded(); try { const container = getTownContainerStub(this.env, this.townId); - await container.fetch('http://container/health'); + await container.fetch("http://container/health"); } catch { // Container may take a moment to start — the alarm will retry } @@ -704,8 +758,11 @@ export class TownDO extends DurableObject { // Proactively clone the rig's repo and create a browse worktree so // the mayor has immediate access to the codebase without waiting for // the first agent dispatch. - this.setupRigRepoInContainer(rigConfig).catch(err => - console.warn(`${TOWN_LOG} configureRig: background repo setup failed:`, err) + this.setupRigRepoInContainer(rigConfig).catch((err) => + console.warn( + `${TOWN_LOG} configureRig: background repo setup failed:`, + err, + ), ); } @@ -732,13 +789,16 @@ export class TownDO extends DurableObject { envVars.KILOCODE_TOKEN = kilocodeToken; } - const containerConfig = await config.buildContainerConfig(this.ctx.storage, this.env); + const containerConfig = await config.buildContainerConfig( + this.ctx.storage, + this.env, + ); const container = getTownContainerStub(this.env, this.townId); - const response = await container.fetch('http://container/repos/setup', { - method: 'POST', + const response = await container.fetch("http://container/repos/setup", { + method: "POST", headers: { - 'Content-Type': 'application/json', - 'X-Town-Config': JSON.stringify(containerConfig), + "Content-Type": "application/json", + "X-Town-Config": JSON.stringify(containerConfig), }, body: JSON.stringify({ rigId: rigConfig.rigId, @@ -750,17 +810,21 @@ export class TownDO extends DurableObject { }); if (!response.ok) { - const text = await response.text().catch(() => '(unreadable)'); + const text = await response.text().catch(() => "(unreadable)"); console.warn( - `${TOWN_LOG} setupRigRepoInContainer: failed for rig=${rigConfig.rigId}: ${response.status} ${text.slice(0, 200)}` + `${TOWN_LOG} setupRigRepoInContainer: failed for rig=${rigConfig.rigId}: ${response.status} ${text.slice(0, 200)}`, ); } else { - console.log(`${TOWN_LOG} setupRigRepoInContainer: accepted for rig=${rigConfig.rigId}`); + console.log( + `${TOWN_LOG} setupRigRepoInContainer: accepted for rig=${rigConfig.rigId}`, + ); } } async getRigConfig(rigId: string): Promise { - return (await this.ctx.storage.get(`rig:${rigId}:config`)) ?? null; + return ( + (await this.ctx.storage.get(`rig:${rigId}:config`)) ?? null + ); } // ══════════════════════════════════════════════════════════════════ @@ -770,14 +834,14 @@ export class TownDO extends DurableObject { async createBead(input: CreateBeadInput): Promise { const bead = beadOps.createBead(this.sql, input); this.emitEvent({ - event: 'bead.created', + event: "bead.created", townId: this.townId, rigId: input.rig_id, beadId: bead.bead_id, beadType: input.type, }); this.broadcastBeadEvent({ - type: 'bead.created', + type: "bead.created", beadId: bead.bead_id, title: bead.title, status: bead.status, @@ -794,11 +858,15 @@ export class TownDO extends DurableObject { return beadOps.listBeads(this.sql, filter); } - async updateBeadStatus(beadId: string, status: string, agentId: string): Promise { + async updateBeadStatus( + beadId: string, + status: string, + agentId: string, + ): Promise { // Record terminal transitions as bead_cancelled events for the reconciler. // Non-terminal transitions are normal lifecycle changes, not cancellations. - if (status === 'closed' || status === 'failed') { - events.insertEvent(this.sql, 'bead_cancelled', { + if (status === "closed" || status === "failed") { + events.insertEvent(this.sql, "bead_cancelled", { bead_id: beadId, payload: { cancel_status: status }, }); @@ -808,10 +876,10 @@ export class TownDO extends DurableObject { // when the bead reaches a terminal status (closed/failed). const bead = beadOps.updateBeadStatus(this.sql, beadId, status, agentId); - if (status === 'closed') { + if (status === "closed") { const durationMs = Date.now() - new Date(bead.created_at).getTime(); this.emitEvent({ - event: 'bead.closed', + event: "bead.closed", townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, @@ -819,33 +887,33 @@ export class TownDO extends DurableObject { durationMs, }); this.broadcastBeadEvent({ - type: 'bead.closed', + type: "bead.closed", beadId, title: bead.title, - status: 'closed', + status: "closed", rigId: bead.rig_id ?? undefined, }); // When a bead closes, check if any blocked beads are now unblocked and dispatch them. this.dispatchUnblockedBeads(beadId); - } else if (status === 'failed') { + } else if (status === "failed") { this.emitEvent({ - event: 'bead.failed', + event: "bead.failed", townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, beadType: bead.type, }); this.broadcastBeadEvent({ - type: 'bead.failed', + type: "bead.failed", beadId, title: bead.title, - status: 'failed', + status: "failed", rigId: bead.rig_id ?? undefined, }); this.dispatchUnblockedBeads(beadId); } else { this.emitEvent({ - event: 'bead.status_changed', + event: "bead.status_changed", townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, @@ -853,7 +921,7 @@ export class TownDO extends DurableObject { label: status, }); this.broadcastBeadEvent({ - type: 'bead.status_changed', + type: "bead.status_changed", beadId, title: bead.title, status, @@ -865,7 +933,7 @@ export class TownDO extends DurableObject { } async closeBead(beadId: string, agentId: string): Promise { - return this.updateBeadStatus(beadId, 'closed', agentId); + return this.updateBeadStatus(beadId, "closed", agentId); } async deleteBead(beadId: string): Promise { @@ -895,12 +963,12 @@ export class TownDO extends DurableObject { status: BeadStatus; metadata: Record; }>, - actorId: string + actorId: string, ): Promise { const bead = beadOps.updateBeadFields(this.sql, beadId, fields, actorId); // When a bead closes via field update, check for newly unblocked beads - if (fields.status === 'closed' || fields.status === 'failed') { + if (fields.status === "closed" || fields.status === "failed") { this.dispatchUnblockedBeads(beadId); } @@ -921,25 +989,25 @@ export class TownDO extends DurableObject { if (hookedBeadId) { // Return the bead to 'open' so the scheduler can re-assign it const bead = beadOps.getBead(this.sql, hookedBeadId); - if (bead && bead.status !== 'closed' && bead.status !== 'failed') { - beadOps.updateBeadStatus(this.sql, hookedBeadId, 'open', agentId); + if (bead && bead.status !== "closed" && bead.status !== "failed") { + beadOps.updateBeadStatus(this.sql, hookedBeadId, "open", agentId); } beadOps.logBeadEvent(this.sql, { beadId: hookedBeadId, agentId, - eventType: 'unhooked', - newValue: 'open', - metadata: { reason: 'agent_reset', actor: 'mayor' }, + eventType: "unhooked", + newValue: "open", + metadata: { reason: "agent_reset", actor: "mayor" }, }); agents.unhookBead(this.sql, agentId); } - agents.updateAgentStatus(this.sql, agentId, 'idle'); + agents.updateAgentStatus(this.sql, agentId, "idle"); console.log( - `${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? 'none'}` + `${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? "none"}`, ); } @@ -949,7 +1017,7 @@ export class TownDO extends DurableObject { */ async updateConvoy( convoyId: string, - fields: Partial<{ merge_mode: ConvoyMergeMode; feature_branch: string }> + fields: Partial<{ merge_mode: ConvoyMergeMode; feature_branch: string }>, ): Promise { const convoy = this.getConvoy(convoyId); if (!convoy) return null; @@ -971,8 +1039,8 @@ export class TownDO extends DurableObject { // Dynamic SET clause — query() can't statically verify param count here, // so use sql.exec() directly. The guard above guarantees values is non-empty. this.sql.exec( - /* sql */ `UPDATE ${convoy_metadata} SET ${setClauses.join(', ')} WHERE ${convoy_metadata.bead_id} = ?`, - ...values + /* sql */ `UPDATE ${convoy_metadata} SET ${setClauses.join(", ")} WHERE ${convoy_metadata.bead_id} = ?`, + ...values, ); // Also update the convoy bead's updated_at @@ -983,7 +1051,7 @@ export class TownDO extends DurableObject { SET ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), convoyId] + [now(), convoyId], ); } @@ -1043,12 +1111,20 @@ export class TownDO extends DurableObject { // ── Agent Events (delegated to AgentDO) ─────────────────────────── - async appendAgentEvent(agentId: string, eventType: string, data: unknown): Promise { + async appendAgentEvent( + agentId: string, + eventType: string, + data: unknown, + ): Promise { const agentDO = getAgentDOStub(this.env, agentId); return agentDO.appendEvent(eventType, data); } - async getAgentEvents(agentId: string, afterId?: number, limit?: number): Promise { + async getAgentEvents( + agentId: string, + afterId?: number, + limit?: number, + ): Promise { const agentDO = getAgentDOStub(this.env, agentId); return agentDO.getEvents(afterId, limit); } @@ -1075,13 +1151,16 @@ export class TownDO extends DurableObject { lastEventType?: string | null; lastEventAt?: string | null; activeTools?: string[]; - } + }, ): Promise { agents.touchAgent(this.sql, agentId, watermark); await this.armAlarmIfNeeded(); } - async updateAgentStatusMessage(agentId: string, message: string): Promise { + async updateAgentStatusMessage( + agentId: string, + message: string, + ): Promise { agents.updateAgentStatusMessage(this.sql, agentId, message); const agent = agents.getAgent(this.sql, agentId); if (agent?.current_hook_bead_id) { @@ -1089,7 +1168,7 @@ export class TownDO extends DurableObject { beadOps.logBeadEvent(this.sql, { beadId: agent.current_hook_bead_id, agentId, - eventType: 'agent_status', + eventType: "agent_status", newValue: message, metadata: { agentId, @@ -1107,7 +1186,7 @@ export class TownDO extends DurableObject { async setAgentDispatchAttempts( agentId: string, attempts: number, - lastActivityAt?: string + lastActivityAt?: string, ): Promise { query( this.sql, @@ -1117,7 +1196,7 @@ export class TownDO extends DurableObject { ${agent_metadata.columns.last_activity_at} = COALESCE(?, ${agent_metadata.columns.last_activity_at}) WHERE ${agent_metadata.bead_id} = ? `, - [attempts, lastActivityAt ?? null, agentId] + [attempts, lastActivityAt ?? null, agentId], ); } @@ -1146,25 +1225,25 @@ export class TownDO extends DurableObject { agentId: string, message: string, options?: { - mode?: 'wait-idle' | 'immediate' | 'queue'; - priority?: 'normal' | 'urgent'; + mode?: "wait-idle" | "immediate" | "queue"; + priority?: "normal" | "urgent"; source?: string; ttlSeconds?: number; - } + }, ): Promise { const nudgeId = crypto.randomUUID(); - const mode = options?.mode ?? 'wait-idle'; - const priority = options?.priority ?? 'normal'; - const source = options?.source ?? 'system'; + const mode = options?.mode ?? "wait-idle"; + const priority = options?.priority ?? "normal"; + const source = options?.source ?? "system"; let expiresAt: string | null = null; - if (mode === 'queue' && options?.ttlSeconds != null) { + if (mode === "queue" && options?.ttlSeconds != null) { // Use SQLite-compatible datetime format (space separator, no Z suffix) so // comparisons against datetime('now') work correctly. expiresAt = new Date(Date.now() + options.ttlSeconds * 1000) .toISOString() - .replace('T', ' ') - .replace('Z', ''); + .replace("T", " ") + .replace("Z", ""); } query( @@ -1180,15 +1259,20 @@ export class TownDO extends DurableObject { ${agent_nudges.columns.expires_at} ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - [nudgeId, agentId, message, mode, priority, source, expiresAt] + [nudgeId, agentId, message, mode, priority, source, expiresAt], ); console.log( - `${TOWN_LOG} queueNudge: nudge_id=${nudgeId} agent=${agentId} mode=${mode} priority=${priority} source=${source}` + `${TOWN_LOG} queueNudge: nudge_id=${nudgeId} agent=${agentId} mode=${mode} priority=${priority} source=${source}`, ); - if (mode === 'immediate') { - const sent = await dispatch.sendMessageToAgent(this.env, this.townId, agentId, message); + if (mode === "immediate") { + const sent = await dispatch.sendMessageToAgent( + this.env, + this.townId, + agentId, + message, + ); if (sent) { query( this.sql, @@ -1197,12 +1281,14 @@ export class TownDO extends DurableObject { SET ${agent_nudges.columns.delivered_at} = datetime('now') WHERE ${agent_nudges.nudge_id} = ? `, - [nudgeId] + [nudgeId], + ); + console.log( + `${TOWN_LOG} queueNudge: immediate nudge delivered to agent=${agentId}`, ); - console.log(`${TOWN_LOG} queueNudge: immediate nudge delivered to agent=${agentId}`); } else { console.warn( - `${TOWN_LOG} queueNudge: immediate delivery failed for agent=${agentId}, nudge queued for retry` + `${TOWN_LOG} queueNudge: immediate delivery failed for agent=${agentId}, nudge queued for retry`, ); } } @@ -1214,10 +1300,14 @@ export class TownDO extends DurableObject { * Return undelivered, non-expired nudges for an agent. * Urgent nudges are returned first, then FIFO within same priority. */ - async getPendingNudges( - agentId: string - ): Promise< - { nudge_id: string; message: string; mode: string; priority: string; source: string }[] + async getPendingNudges(agentId: string): Promise< + { + nudge_id: string; + message: string; + mode: string; + priority: string; + source: string; + }[] > { const rows = [ ...query( @@ -1237,7 +1327,7 @@ export class TownDO extends DurableObject { CASE ${agent_nudges.priority} WHEN 'urgent' THEN 0 ELSE 1 END ASC, ${agent_nudges.created_at} ASC `, - [agentId] + [agentId], ), ]; @@ -1261,7 +1351,7 @@ export class TownDO extends DurableObject { SET ${agent_nudges.columns.delivered_at} = datetime('now') WHERE ${agent_nudges.nudge_id} = ? `, - [nudgeId] + [nudgeId], ); } @@ -1281,7 +1371,7 @@ export class TownDO extends DurableObject { AND ${agent_nudges.delivered_at} IS NULL RETURNING ${agent_nudges.nudge_id} `, - [] + [], ), ]; @@ -1295,7 +1385,7 @@ export class TownDO extends DurableObject { async submitToReviewQueue(input: ReviewQueueInput): Promise { reviewQueue.submitToReviewQueue(this.sql, input); this.emitEvent({ - event: 'review.submitted', + event: "review.submitted", townId: this.townId, rigId: input.rig_id, beadId: input.bead_id, @@ -1307,13 +1397,16 @@ export class TownDO extends DurableObject { return reviewQueue.popReviewQueue(this.sql); } - async completeReview(entryId: string, status: 'merged' | 'failed'): Promise { + async completeReview( + entryId: string, + status: "merged" | "failed", + ): Promise { reviewQueue.completeReview(this.sql, entryId, status); } async completeReviewWithResult(input: { entry_id: string; - status: 'merged' | 'failed' | 'conflict'; + status: "merged" | "failed" | "conflict"; message?: string; commit_sha?: string; }): Promise { @@ -1321,13 +1414,15 @@ export class TownDO extends DurableObject { // trigger dispatchUnblockedBeads for it after the MR closes. const mrBead = beadOps.getBead(this.sql, input.entry_id); const sourceBeadId = - typeof mrBead?.metadata?.source_bead_id === 'string' ? mrBead.metadata.source_bead_id : null; + typeof mrBead?.metadata?.source_bead_id === "string" + ? mrBead.metadata.source_bead_id + : null; reviewQueue.completeReviewWithResult(this.sql, input); - if (input.status === 'merged') { + if (input.status === "merged") { this.emitEvent({ - event: 'review.completed', + event: "review.completed", townId: this.townId, beadId: input.entry_id, }); @@ -1337,9 +1432,9 @@ export class TownDO extends DurableObject { if (sourceBeadId) { this.dispatchUnblockedBeads(sourceBeadId); } - } else if (input.status === 'failed' || input.status === 'conflict') { + } else if (input.status === "failed" || input.status === "conflict") { this.emitEvent({ - event: 'review.failed', + event: "review.failed", townId: this.townId, beadId: input.entry_id, }); @@ -1356,7 +1451,7 @@ export class TownDO extends DurableObject { // applies all pending events before reconciliation runs. DO RPCs // are serialized, so agentCompleted can't race with this — it // waits for agentDone to finish before executing. - events.insertEvent(this.sql, 'agent_done', { + events.insertEvent(this.sql, "agent_done", { agent_id: agentId, payload: { branch: input.branch, @@ -1369,19 +1464,19 @@ export class TownDO extends DurableObject { async agentCompleted( agentId: string, - input: { status: 'completed' | 'failed'; reason?: string } + input: { status: "completed" | "failed"; reason?: string }, ): Promise { // Resolve empty agentId to mayor (backwards compat with container callback) let resolvedAgentId = agentId; if (!resolvedAgentId) { - const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0]; + const mayor = agents.listAgents(this.sql, { role: "mayor" })[0]; if (mayor) resolvedAgentId = mayor.id; } // Event-only: record the fact. The alarm's Phase 0 drains and // applies all pending events. DO RPCs are serialized so there's // no race with agentDone. - events.insertEvent(this.sql, 'agent_completed', { + events.insertEvent(this.sql, "agent_completed", { agent_id: resolvedAgentId || agentId, payload: { status: input.status, @@ -1393,7 +1488,7 @@ export class TownDO extends DurableObject { if (resolvedAgentId) { const agent = agents.getAgent(this.sql, resolvedAgentId); this.emitEvent({ - event: 'agent.exited', + event: "agent.exited", townId: this.townId, agentId: resolvedAgentId, role: agent?.role, @@ -1412,33 +1507,39 @@ export class TownDO extends DurableObject { */ async requestChanges( agentId: string, - input: { feedback: string; files?: string[] } + input: { feedback: string; files?: string[] }, ): Promise<{ rework_bead_id: string }> { const agent = agents.getAgent(this.sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); - if (agent.role !== 'refinery') throw new Error(`Only refineries can request changes`); - if (!agent.current_hook_bead_id) throw new Error(`Agent ${agentId} is not hooked to a bead`); + if (agent.role !== "refinery") + throw new Error(`Only refineries can request changes`); + if (!agent.current_hook_bead_id) + throw new Error(`Agent ${agentId} is not hooked to a bead`); const mrBead = beadOps.getBead(this.sql, agent.current_hook_bead_id); - if (!mrBead || mrBead.type !== 'merge_request') { + if (!mrBead || mrBead.type !== "merge_request") { throw new Error(`Agent ${agentId} is not hooked to a merge_request bead`); } // Find the source bead (the original issue the polecat worked on) const sourceBeadId = - typeof mrBead.metadata?.source_bead_id === 'string' ? mrBead.metadata.source_bead_id : null; - const sourceBead = sourceBeadId ? beadOps.getBead(this.sql, sourceBeadId) : null; + typeof mrBead.metadata?.source_bead_id === "string" + ? mrBead.metadata.source_bead_id + : null; + const sourceBead = sourceBeadId + ? beadOps.getBead(this.sql, sourceBeadId) + : null; // Get branch info from review_metadata const reviewMeta = reviewQueue.getReviewMetadata(this.sql, mrBead.bead_id); const reworkBead = beadOps.createBead(this.sql, { - type: 'issue', + type: "issue", title: `Rework: ${sourceBead?.title ?? mrBead.title}`, body: input.feedback, - priority: sourceBead?.priority ?? 'medium', + priority: sourceBead?.priority ?? "medium", rig_id: mrBead.rig_id ?? undefined, - labels: ['gt:rework'], + labels: ["gt:rework"], metadata: { rework_for: sourceBeadId, mr_bead_id: mrBead.bead_id, @@ -1449,24 +1550,29 @@ export class TownDO extends DurableObject { }); // Rework bead blocks the MR bead — MR can't proceed until rework is done - beadOps.insertDependency(this.sql, mrBead.bead_id, reworkBead.bead_id, 'blocks'); + beadOps.insertDependency( + this.sql, + mrBead.bead_id, + reworkBead.bead_id, + "blocks", + ); // Record event so the reconciler picks up the rework bead - events.insertEvent(this.sql, 'bead_created', { + events.insertEvent(this.sql, "bead_created", { bead_id: reworkBead.bead_id, - payload: { bead_type: 'issue', rig_id: mrBead.rig_id }, + payload: { bead_type: "issue", rig_id: mrBead.rig_id }, }); beadOps.logBeadEvent(this.sql, { beadId: mrBead.bead_id, agentId, - eventType: 'rework_requested', + eventType: "rework_requested", newValue: reworkBead.bead_id, metadata: { feedback: input.feedback.slice(0, 500), files: input.files }, }); console.log( - `${TOWN_LOG} requestChanges: refinery=${agentId} mr=${mrBead.bead_id} rework=${reworkBead.bead_id}` + `${TOWN_LOG} requestChanges: refinery=${agentId} mr=${mrBead.bead_id} rework=${reworkBead.bead_id}`, ); await this.armAlarmIfNeeded(); @@ -1486,25 +1592,29 @@ export class TownDO extends DurableObject { }): Promise { const triageBead = beadOps.getBead(this.sql, input.triage_request_bead_id); if (!triageBead) - throw new Error(`Triage request bead ${input.triage_request_bead_id} not found`); + throw new Error( + `Triage request bead ${input.triage_request_bead_id} not found`, + ); if (!triageBead.labels.includes(patrol.TRIAGE_REQUEST_LABEL)) { - throw new Error(`Bead ${input.triage_request_bead_id} is not a triage request`); + throw new Error( + `Bead ${input.triage_request_bead_id} is not a triage request`, + ); } - if (triageBead.status !== 'open') { + if (triageBead.status !== "open") { throw new Error( - `Triage request ${input.triage_request_bead_id} is already ${triageBead.status} — cannot resolve again` + `Triage request ${input.triage_request_bead_id} is already ${triageBead.status} — cannot resolve again`, ); } // ── Apply the chosen action ──────────────────────────────────── const targetAgentId = - typeof triageBead.metadata?.agent_bead_id === 'string' + typeof triageBead.metadata?.agent_bead_id === "string" ? triageBead.metadata.agent_bead_id : null; // Use the hooked bead ID captured when the triage request was created, // not the agent's current hook (which may have changed since then). const snapshotHookedBeadId = - typeof triageBead.metadata?.hooked_bead_id === 'string' + typeof triageBead.metadata?.hooked_bead_id === "string" ? triageBead.metadata.hooked_bead_id : null; const action = input.action.toUpperCase(); @@ -1513,19 +1623,24 @@ export class TownDO extends DurableObject { const targetAgent = agents.getAgent(this.sql, targetAgentId); switch (action) { - case 'RESTART': - case 'RESTART_WITH_BACKOFF': { + case "RESTART": + case "RESTART_WITH_BACKOFF": { // Stop the agent in the container, reset to idle so the // scheduler picks it up again on the next alarm cycle. - if (targetAgent?.status === 'working' || targetAgent?.status === 'stalled') { - dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); + if ( + targetAgent?.status === "working" || + targetAgent?.status === "stalled" + ) { + dispatch + .stopAgentInContainer(this.env, this.townId, targetAgentId) + .catch(() => {}); } if (targetAgent) { // RESTART clears last_activity_at so the scheduler picks it // up immediately. RESTART_WITH_BACKOFF sets it to now() so // the dispatch cooldown (DISPATCH_COOLDOWN_MS) delays the // next attempt, preventing immediate restart of crash loops. - const activityAt = action === 'RESTART_WITH_BACKOFF' ? now() : null; + const activityAt = action === "RESTART_WITH_BACKOFF" ? now() : null; query( this.sql, /* sql */ ` @@ -1534,72 +1649,93 @@ export class TownDO extends DurableObject { ${agent_metadata.columns.last_activity_at} = ? WHERE ${agent_metadata.bead_id} = ? `, - [activityAt, targetAgentId] + [activityAt, targetAgentId], ); } break; } - case 'CLOSE_BEAD': { + case "CLOSE_BEAD": { // Fail the bead that was hooked when the triage request was // created (not the agent's current hook, which may differ). - const beadToClose = snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; + const beadToClose = + snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; if (beadToClose) { - beadOps.updateBeadStatus(this.sql, beadToClose, 'failed', input.agent_id); + beadOps.updateBeadStatus( + this.sql, + beadToClose, + "failed", + input.agent_id, + ); // Only stop and unhook if the agent is still working on this // specific bead. If the agent has moved on, stopping it would // abort unrelated work. if (targetAgent?.current_hook_bead_id === beadToClose) { - if (targetAgent.status === 'working' || targetAgent.status === 'stalled') { - dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); + if ( + targetAgent.status === "working" || + targetAgent.status === "stalled" + ) { + dispatch + .stopAgentInContainer(this.env, this.townId, targetAgentId) + .catch(() => {}); } agents.unhookBead(this.sql, targetAgentId); } } break; } - case 'ESCALATE_TO_MAYOR': - case 'ESCALATE': { - const message = input.resolution_notes || triageBead.title || 'Triage escalation'; + case "ESCALATE_TO_MAYOR": + case "ESCALATE": { + const message = + input.resolution_notes || triageBead.title || "Triage escalation"; this.sendMayorMessage( - `[Triage Escalation] ${message}\n\nAgent: ${targetAgentId ?? 'unknown'}\nBead: ${snapshotHookedBeadId ?? 'unknown'}` - ).catch(err => - console.warn(`${TOWN_LOG} resolveTriage: mayor notification failed:`, err) + `[Triage Escalation] ${message}\n\nAgent: ${targetAgentId ?? "unknown"}\nBead: ${snapshotHookedBeadId ?? "unknown"}`, + ).catch((err) => + console.warn( + `${TOWN_LOG} resolveTriage: mayor notification failed:`, + err, + ), ); break; } - case 'NUDGE': { + case "NUDGE": { // Nudge the stuck agent — time-sensitive, deliver immediately if (targetAgent && targetAgentId) { this.queueNudge( targetAgentId, input.resolution_notes || - 'The triage system has flagged you as potentially stuck. Please report your status.', - { mode: 'immediate', source: 'triage', priority: 'urgent' } - ).catch(err => + "The triage system has flagged you as potentially stuck. Please report your status.", + { mode: "immediate", source: "triage", priority: "urgent" }, + ).catch((err) => console.warn( `${TOWN_LOG} resolveTriage: nudge failed for agent=${targetAgentId}:`, - err - ) + err, + ), ); this.emitEvent({ - event: 'nudge.queued', + event: "nudge.queued", townId: this.townId, agentId: targetAgentId, - label: 'triage_nudge', + label: "triage_nudge", }); } break; } - case 'REASSIGN_BEAD': { + case "REASSIGN_BEAD": { // Target the bead from the triage snapshot, not the agent's current hook. - const beadToReassign = snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; + const beadToReassign = + snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; if (beadToReassign) { // Only stop and unhook if the agent is still working on this // specific bead. If the agent has moved on, stopping it would // abort unrelated work. if (targetAgent?.current_hook_bead_id === beadToReassign) { - if (targetAgent.status === 'working' || targetAgent.status === 'stalled') { - dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); + if ( + targetAgent.status === "working" || + targetAgent.status === "stalled" + ) { + dispatch + .stopAgentInContainer(this.env, this.townId, targetAgentId) + .catch(() => {}); } agents.unhookBead(this.sql, targetAgentId); } @@ -1615,7 +1751,7 @@ export class TownDO extends DurableObject { AND ${beads.status} != 'closed' AND ${beads.status} != 'failed' `, - [now(), beadToReassign] + [now(), beadToReassign], ); } break; @@ -1652,15 +1788,15 @@ export class TownDO extends DurableObject { input.resolution_notes, input.agent_id, input.triage_request_bead_id, - ] + ], ); beadOps.logBeadEvent(this.sql, { beadId: input.triage_request_bead_id, agentId: input.agent_id, - eventType: 'status_changed', + eventType: "status_changed", oldValue: triageBead.status, - newValue: 'closed', + newValue: "closed", metadata: { action: input.action, resolution_notes: input.resolution_notes, @@ -1674,7 +1810,7 @@ export class TownDO extends DurableObject { beadOps.logBeadEvent(this.sql, { beadId: targetBeadId, agentId: input.agent_id, - eventType: 'triage_resolved', + eventType: "triage_resolved", newValue: action, metadata: { action, @@ -1690,21 +1826,29 @@ export class TownDO extends DurableObject { // The escalation_bead_id is nested under metadata.context (set by // createTriageRequest's TriageRequestMetadata structure). const ctx = - typeof triageBead.metadata?.context === 'object' && triageBead.metadata.context !== null + typeof triageBead.metadata?.context === "object" && + triageBead.metadata.context !== null ? (triageBead.metadata.context as Record) : null; const escalationBeadId = - typeof ctx?.escalation_bead_id === 'string' ? ctx.escalation_bead_id : null; + typeof ctx?.escalation_bead_id === "string" + ? ctx.escalation_bead_id + : null; if (escalationBeadId) { - beadOps.updateBeadStatus(this.sql, escalationBeadId, 'closed', input.agent_id); + beadOps.updateBeadStatus( + this.sql, + escalationBeadId, + "closed", + input.agent_id, + ); } console.log( - `${TOWN_LOG} resolveTriage: bead=${input.triage_request_bead_id} action=${input.action}` + `${TOWN_LOG} resolveTriage: bead=${input.triage_request_bead_id} action=${input.action}`, ); const updated = beadOps.getBead(this.sql, input.triage_request_bead_id); - if (!updated) throw new Error('Triage bead not found after update'); + if (!updated) throw new Error("Triage bead not found after update"); return updated; } @@ -1713,12 +1857,15 @@ export class TownDO extends DurableObject { } async getMoleculeCurrentStep( - agentId: string + agentId: string, ): Promise<{ molecule: Molecule; step: unknown } | null> { return reviewQueue.getMoleculeCurrentStep(this.sql, agentId); } - async advanceMoleculeStep(agentId: string, summary: string): Promise { + async advanceMoleculeStep( + agentId: string, + summary: string, + ): Promise { return reviewQueue.advanceMoleculeStep(this.sql, agentId, summary); } @@ -1734,23 +1881,28 @@ export class TownDO extends DurableObject { metadata?: Record; }): Promise<{ bead: Bead; agent: Agent }> { const createdBead = beadOps.createBead(this.sql, { - type: 'issue', + type: "issue", title: input.title, body: input.body, - priority: BeadPriority.catch('medium').parse(input.priority ?? 'medium'), + priority: BeadPriority.catch("medium").parse(input.priority ?? "medium"), rig_id: input.rigId, metadata: input.metadata, }); - events.insertEvent(this.sql, 'bead_created', { + events.insertEvent(this.sql, "bead_created", { bead_id: createdBead.bead_id, - payload: { bead_type: 'issue', rig_id: input.rigId, has_blockers: false }, + payload: { bead_type: "issue", rig_id: input.rigId, has_blockers: false }, }); // Fast path: assign agent immediately for UX ("Toast is on it!") // rather than waiting for the next alarm tick. Uses the same // getOrCreateAgent + hookBead path the reconciler would use. - const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); + 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) @@ -1759,8 +1911,11 @@ export class TownDO extends DurableObject { // 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) + this.dispatchAgent(hookedAgent, bead).catch((err) => + console.error( + `${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, + err, + ), ); await this.armAlarmIfNeeded(); return { bead, agent: hookedAgent }; @@ -1768,11 +1923,16 @@ export class TownDO extends DurableObject { /** Build the rig list for mayor agent startup (browse worktree setup on fresh containers). */ private async rigListForMayor(): Promise< - Array<{ rigId: string; gitUrl: string; defaultBranch: string; platformIntegrationId?: string }> + Array<{ + rigId: string; + gitUrl: string; + defaultBranch: string; + platformIntegrationId?: string; + }> > { const rigRecords = rigs.listRigs(this.sql); return Promise.all( - rigRecords.map(async r => { + rigRecords.map(async (r) => { const rc = await this.getRigConfig(r.id); return { rigId: r.id, @@ -1780,7 +1940,7 @@ export class TownDO extends DurableObject { defaultBranch: r.default_branch, platformIntegrationId: rc?.platformIntegrationId, }; - }) + }), ); } @@ -1791,25 +1951,34 @@ export class TownDO extends DurableObject { async sendMayorMessage( message: string, _model?: string, - uiContext?: string - ): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> { + uiContext?: string, + ): Promise<{ + agentId: string; + sessionStatus: "idle" | "active" | "starting"; + }> { const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + let mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; mayor = agents.registerAgent(this.sql, { - role: 'mayor', - name: 'mayor', + role: "mayor", + name: "mayor", identity, }); } - const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); - const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; + const containerStatus = await dispatch.checkAgentContainerStatus( + this.env, + townId, + mayor.id, + ); + const isAlive = + containerStatus.status === "running" || + containerStatus.status === "starting"; console.log( - `${TOWN_LOG} sendMayorMessage: townId=${townId} mayorId=${mayor.id} containerStatus=${containerStatus.status} isAlive=${isAlive}` + `${TOWN_LOG} sendMayorMessage: townId=${townId} mayorId=${mayor.id} containerStatus=${containerStatus.status} isAlive=${isAlive}`, ); const effectiveContext = uiContext ?? this._dashboardContext; @@ -1817,53 +1986,62 @@ export class TownDO extends DurableObject { ? `\n${effectiveContext}\n\n\n${message}` : message; - let sessionStatus: 'idle' | 'active' | 'starting'; + let sessionStatus: "idle" | "active" | "starting"; if (isAlive) { - const sent = await dispatch.sendMessageToAgent(this.env, townId, mayor.id, combinedMessage); - sessionStatus = sent ? 'active' : 'idle'; + const sent = await dispatch.sendMessageToAgent( + this.env, + townId, + mayor.id, + combinedMessage, + ); + sessionStatus = sent ? "active" : "idle"; } else { const townConfig = await this.getTownConfig(); const rigConfig = await this.getMayorRigConfig(); const kilocodeToken = await this.resolveKilocodeToken(); console.log( - `${TOWN_LOG} sendMayorMessage: townId=${townId} hasRigConfig=${!!rigConfig} hasKilocodeToken=${!!kilocodeToken} townConfigToken=${!!townConfig.kilocode_token} rigConfigToken=${!!rigConfig?.kilocodeToken}` + `${TOWN_LOG} sendMayorMessage: townId=${townId} hasRigConfig=${!!rigConfig} hasKilocodeToken=${!!kilocodeToken} townConfigToken=${!!townConfig.kilocode_token} rigConfigToken=${!!rigConfig?.kilocodeToken}`, ); if (kilocodeToken) { try { const containerStub = getTownContainerStub(this.env, townId); - await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); + await containerStub.setEnvVar("KILOCODE_TOKEN", kilocodeToken); } catch { // Best effort } } - const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { - townId, - rigId: `mayor-${townId}`, - userId: townConfig.owner_user_id ?? rigConfig?.userId ?? townId, - agentId: mayor.id, - agentName: 'mayor', - role: 'mayor', - identity: mayor.identity, - beadId: '', - beadTitle: message, - beadBody: '', - checkpoint: null, - gitUrl: rigConfig?.gitUrl ?? '', - defaultBranch: rigConfig?.defaultBranch ?? 'main', - kilocodeToken, - townConfig, - rigs: await this.rigListForMayor(), - }); + const started = await dispatch.startAgentInContainer( + this.env, + this.ctx.storage, + { + townId, + rigId: `mayor-${townId}`, + userId: townConfig.owner_user_id ?? rigConfig?.userId ?? townId, + agentId: mayor.id, + agentName: "mayor", + role: "mayor", + identity: mayor.identity, + beadId: "", + beadTitle: message, + beadBody: "", + checkpoint: null, + gitUrl: rigConfig?.gitUrl ?? "", + defaultBranch: rigConfig?.defaultBranch ?? "main", + kilocodeToken, + townConfig, + rigs: await this.rigListForMayor(), + }, + ); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, 'working'); - sessionStatus = 'starting'; + agents.updateAgentStatus(this.sql, mayor.id, "working"); + sessionStatus = "starting"; } else { - sessionStatus = 'idle'; + sessionStatus = "idle"; } } @@ -1876,26 +2054,38 @@ export class TownDO extends DurableObject { * Called eagerly on page load so the terminal is available immediately * without requiring the user to send a message first. */ - async ensureMayor(): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> { + async ensureMayor(): Promise<{ + agentId: string; + sessionStatus: "idle" | "active" | "starting"; + }> { const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + let mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; mayor = agents.registerAgent(this.sql, { - role: 'mayor', - name: 'mayor', + role: "mayor", + name: "mayor", identity, }); console.log(`${TOWN_LOG} ensureMayor: created mayor agent ${mayor.id}`); } // Check if the container is already running - const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); - const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; + const containerStatus = await dispatch.checkAgentContainerStatus( + this.env, + townId, + mayor.id, + ); + const isAlive = + containerStatus.status === "running" || + containerStatus.status === "starting"; if (isAlive) { - const status = mayor.status === 'working' || mayor.status === 'stalled' ? 'active' : 'idle'; + const status = + mayor.status === "working" || mayor.status === "stalled" + ? "active" + : "idle"; return { agentId: mayor.id, sessionStatus: status }; } @@ -1909,45 +2099,54 @@ export class TownDO extends DurableObject { // will retry via status polling once a rig is created and the token // becomes available. if (!kilocodeToken) { - console.warn(`${TOWN_LOG} ensureMayor: no kilocodeToken available, deferring start`); - return { agentId: mayor.id, sessionStatus: 'idle' }; + console.warn( + `${TOWN_LOG} ensureMayor: no kilocodeToken available, deferring start`, + ); + return { agentId: mayor.id, sessionStatus: "idle" }; } try { const containerStub = getTownContainerStub(this.env, townId); - await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); + await containerStub.setEnvVar("KILOCODE_TOKEN", kilocodeToken); } catch { // Best effort } // Start with an empty prompt — the mayor will be idle but its container // and SDK server will be running, ready for PTY connections. - const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { - townId, - rigId: `mayor-${townId}`, - userId: - townConfig.owner_user_id ?? rigConfig?.userId ?? townConfig.created_by_user_id ?? townId, - agentId: mayor.id, - agentName: 'mayor', - role: 'mayor', - identity: mayor.identity, - beadId: '', - beadTitle: 'Mayor ready. Waiting for instructions.', - beadBody: '', - checkpoint: null, - gitUrl: rigConfig?.gitUrl ?? '', - defaultBranch: rigConfig?.defaultBranch ?? 'main', - kilocodeToken, - townConfig, - rigs: await this.rigListForMayor(), - }); + const started = await dispatch.startAgentInContainer( + this.env, + this.ctx.storage, + { + townId, + rigId: `mayor-${townId}`, + userId: + townConfig.owner_user_id ?? + rigConfig?.userId ?? + townConfig.created_by_user_id ?? + townId, + agentId: mayor.id, + agentName: "mayor", + role: "mayor", + identity: mayor.identity, + beadId: "", + beadTitle: "Mayor ready. Waiting for instructions.", + beadBody: "", + checkpoint: null, + gitUrl: rigConfig?.gitUrl ?? "", + defaultBranch: rigConfig?.defaultBranch ?? "main", + kilocodeToken, + townConfig, + rigs: await this.rigListForMayor(), + }, + ); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, 'working'); - return { agentId: mayor.id, sessionStatus: 'starting' }; + agents.updateAgentStatus(this.sql, mayor.id, "working"); + return { agentId: mayor.id, sessionStatus: "starting" }; } - return { agentId: mayor.id, sessionStatus: 'idle' }; + return { agentId: mayor.id, sessionStatus: "idle" }; } async getMayorStatus(): Promise<{ @@ -1956,20 +2155,20 @@ export class TownDO extends DurableObject { session: { agentId: string; sessionId: string; - status: 'idle' | 'active' | 'starting'; + status: "idle" | "active" | "starting"; lastActivityAt: string; } | null; }> { - const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + const mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; - const mapStatus = (agentStatus: string): 'idle' | 'active' | 'starting' => { + const mapStatus = (agentStatus: string): "idle" | "active" | "starting" => { switch (agentStatus) { - case 'working': - return 'active'; - case 'stalled': - return 'active'; + case "working": + return "active"; + case "stalled": + return "active"; default: - return 'idle'; + return "idle"; } }; @@ -2021,7 +2220,11 @@ export class TownDO extends DurableObject { const parsed = z .object({ title: z.string().min(1), - beads: z.array(z.object({ bead_id: z.string().min(1), rig_id: z.string().min(1) })).min(1), + beads: z + .array( + z.object({ bead_id: z.string().min(1), rig_id: z.string().min(1) }), + ) + .min(1), created_by: z.string().min(1).optional(), }) .parse(input); @@ -2044,21 +2247,21 @@ export class TownDO extends DurableObject { `, [ convoyId, - 'convoy', - 'open', + "convoy", + "open", parsed.title, null, null, null, null, - 'medium', - JSON.stringify(['gt:convoy']), - '{}', + "medium", + JSON.stringify(["gt:convoy"]), + "{}", parsed.created_by ?? null, timestamp, timestamp, null, - ] + ], ); // Create convoy_metadata @@ -2070,7 +2273,7 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.closed_beads}, ${convoy_metadata.columns.landed_at} ) VALUES (?, ?, ?, ?) `, - [convoyId, parsed.beads.length, 0, null] + [convoyId, parsed.beads.length, 0, null], ); // Track beads via bead_dependencies @@ -2084,21 +2287,24 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [bead.bead_id, convoyId, 'tracks'] + [bead.bead_id, convoyId, "tracks"], ); } const convoy = this.getConvoy(convoyId); - if (!convoy) throw new Error('Failed to create convoy'); + if (!convoy) throw new Error("Failed to create convoy"); this.emitEvent({ - event: 'convoy.created', + event: "convoy.created", townId: this.townId, convoyId, }); return convoy; } - async onBeadClosed(input: { convoyId: string; beadId: string }): Promise { + async onBeadClosed(input: { + convoyId: string; + beadId: string; + }): Promise { // Count closed tracked beads const closedRows = [ ...query( @@ -2110,10 +2316,12 @@ export class TownDO extends DurableObject { AND ${bead_dependencies.dependency_type} = 'tracks' AND ${beads.status} = 'closed' `, - [input.convoyId] + [input.convoyId], ), ]; - const closedCount = z.object({ count: z.number() }).parse(closedRows[0] ?? { count: 0 }).count; + const closedCount = z + .object({ count: z.number() }) + .parse(closedRows[0] ?? { count: 0 }).count; query( this.sql, @@ -2122,14 +2330,22 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.closed_beads} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [closedCount, input.convoyId] + [closedCount, input.convoyId], ); const convoy = this.getConvoy(input.convoyId); if (convoy) { - this.broadcastConvoyProgress(input.convoyId, convoy.total_beads, convoy.closed_beads); + this.broadcastConvoyProgress( + input.convoyId, + convoy.total_beads, + convoy.closed_beads, + ); } - if (convoy && convoy.status === 'active' && convoy.closed_beads >= convoy.total_beads) { + if ( + convoy && + convoy.status === "active" && + convoy.closed_beads >= convoy.total_beads + ) { const timestamp = now(); query( this.sql, @@ -2138,7 +2354,7 @@ export class TownDO extends DurableObject { SET ${beads.columns.status} = 'closed', ${beads.columns.closed_at} = ?, ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, input.convoyId] + [timestamp, timestamp, input.convoyId], ); query( this.sql, @@ -2147,10 +2363,10 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [timestamp, input.convoyId] + [timestamp, input.convoyId], ); this.emitEvent({ - event: 'convoy.landed', + event: "convoy.landed", townId: this.townId, convoyId: input.convoyId, }); @@ -2180,7 +2396,7 @@ export class TownDO extends DurableObject { WHERE ${bead_dependencies.depends_on_bead_id} = ? AND ${bead_dependencies.dependency_type} = 'tracks' `, - [convoyId] + [convoyId], ), ]; @@ -2192,7 +2408,7 @@ export class TownDO extends DurableObject { for (const raw of trackedRows) { const row = TrackedRow.parse(raw); - if (row.status === 'closed' || row.status === 'failed') continue; + if (row.status === "closed" || row.status === "failed") continue; // Unhook agent if still assigned if (row.assignee_agent_bead_id) { @@ -2201,18 +2417,18 @@ export class TownDO extends DurableObject { } catch (err) { console.warn( `${TOWN_LOG} closeConvoy: unhookBead failed for agent=${row.assignee_agent_bead_id}`, - err + err, ); } } - beadOps.updateBeadStatus(this.sql, row.bead_id, 'closed', 'system'); + beadOps.updateBeadStatus(this.sql, row.bead_id, "closed", "system"); } // Close the convoy bead itself if not already auto-landed by // updateConvoyProgress (which fires when the last tracked bead closes). const current = this.getConvoy(convoyId); - if (current && current.status !== 'landed') { + if (current && current.status !== "landed") { query( this.sql, /* sql */ ` @@ -2222,7 +2438,7 @@ export class TownDO extends DurableObject { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, convoyId] + [timestamp, timestamp, convoyId], ); query( this.sql, @@ -2232,7 +2448,7 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [timestamp, convoyId] + [timestamp, convoyId], ); } @@ -2248,9 +2464,12 @@ export class TownDO extends DurableObject { rigId: string; convoyTitle: string; tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; - merge_mode?: 'review-then-land' | 'review-and-merge'; + merge_mode?: "review-then-land" | "review-and-merge"; staged?: boolean; - }): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent | null }> }> { + }): Promise<{ + convoy: ConvoyEntry; + beads: Array<{ bead: Bead; agent: Agent | null }>; + }> { // Resolve staged: explicit request wins, otherwise fall back to town config default. const townConfig = await this.getTownConfig(); const isStaged = input.staged ?? townConfig.staged_convoys_default; @@ -2267,9 +2486,9 @@ export class TownDO extends DurableObject { const convoySlug = input.convoyTitle .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 40) || 'convoy'; + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40) || "convoy"; const featureBranch = `convoy/${convoySlug}/${convoyId.slice(0, 8)}/head`; // 1. Validate the dependency graph has no cycles BEFORE persisting anything. @@ -2283,7 +2502,8 @@ export class TownDO extends DurableObject { } for (let i = 0; i < input.tasks.length; i++) { for (const depIdx of input.tasks[i].depends_on ?? []) { - if (depIdx < 0 || depIdx >= input.tasks.length || depIdx === i) continue; + if (depIdx < 0 || depIdx >= input.tasks.length || depIdx === i) + continue; (adj.get(depIdx) ?? []).push(i); inDegree.set(i, (inDegree.get(i) ?? 0) + 1); } @@ -2305,7 +2525,7 @@ export class TownDO extends DurableObject { } if (visited < input.tasks.length) { throw new Error( - `Convoy dependency graph contains a cycle — ${input.tasks.length - visited} tasks are involved in circular dependencies` + `Convoy dependency graph contains a cycle — ${input.tasks.length - visited} tasks are involved in circular dependencies`, ); } } @@ -2325,24 +2545,24 @@ export class TownDO extends DurableObject { `, [ convoyId, - 'convoy', - 'open', + "convoy", + "open", input.convoyTitle, null, // body null, // rig_id — intentionally null; a convoy is a town-level grouping that can span multiple rigs null, // parent_bead_id null, // assignee_agent_bead_id - 'medium', - JSON.stringify(['gt:convoy']), + "medium", + JSON.stringify(["gt:convoy"]), JSON.stringify({ feature_branch: featureBranch }), null, timestamp, timestamp, null, - ] + ], ); - const mergeMode = input.merge_mode ?? 'review-then-land'; + const mergeMode = input.merge_mode ?? "review-then-land"; const stagedValue = isStaged ? 1 : 0; @@ -2356,7 +2576,15 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.staged} ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - [convoyId, input.tasks.length, 0, null, featureBranch, mergeMode, stagedValue] + [ + convoyId, + input.tasks.length, + 0, + null, + featureBranch, + mergeMode, + stagedValue, + ], ); // 2. Create all beads and track their IDs (needed for depends_on resolution) @@ -2365,10 +2593,10 @@ export class TownDO extends DurableObject { for (const task of input.tasks) { const createdBead = beadOps.createBead(this.sql, { - type: 'issue', + type: "issue", title: task.title, body: task.body, - priority: 'medium', + priority: "medium", rig_id: input.rigId, metadata: { convoy_id: convoyId, feature_branch: featureBranch }, }); @@ -2384,7 +2612,7 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [createdBead.bead_id, convoyId, 'tracks'] + [createdBead.bead_id, convoyId, "tracks"], ); } @@ -2403,7 +2631,7 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [beadIds[i], beadIds[depIdx], 'blocks'] + [beadIds[i], beadIds[depIdx], "blocks"], ); } } @@ -2411,10 +2639,10 @@ export class TownDO extends DurableObject { // Record bead_created events for reconciler (dual-write, no behavior change) for (let i = 0; i < beadIds.length; i++) { const hasBlockers = (input.tasks[i].depends_on ?? []).length > 0; - events.insertEvent(this.sql, 'bead_created', { + events.insertEvent(this.sql, "bead_created", { bead_id: beadIds[i], payload: { - bead_type: 'issue', + bead_type: "issue", rig_id: input.rigId, convoy_id: convoyId, has_blockers: hasBlockers, @@ -2437,9 +2665,9 @@ export class TownDO extends DurableObject { } const convoy = this.getConvoy(convoyId); - if (!convoy) throw new Error('Failed to create convoy'); + if (!convoy) throw new Error("Failed to create convoy"); this.emitEvent({ - event: 'convoy.created', + event: "convoy.created", townId: this.townId, convoyId, }); @@ -2449,9 +2677,10 @@ export class TownDO extends DurableObject { /** * Transition a staged convoy to active: hook agents and begin dispatch. */ - async startConvoy( - convoyId: string - ): Promise<{ convoy: ConvoyEntry; beads: Array<{ bead: Bead; agent: Agent | null }> }> { + async startConvoy(convoyId: string): Promise<{ + convoy: ConvoyEntry; + beads: Array<{ bead: Bead; agent: Agent | null }>; + }> { const convoy = this.getConvoy(convoyId); if (!convoy) throw new Error(`Convoy not found: ${convoyId}`); if (!convoy.staged) throw new Error(`Convoy is not staged: ${convoyId}`); @@ -2466,14 +2695,14 @@ export class TownDO extends DurableObject { WHERE ${bead_dependencies.depends_on_bead_id} = ? AND ${bead_dependencies.dependency_type} = 'tracks' `, - [convoyId] + [convoyId], ), ]; const BeadIdRow = z.object({ bead_id: z.string() }); const trackedBeadIds = BeadIdRow.array() .parse(trackedRows) - .map(r => r.bead_id); + .map((r) => r.bead_id); const results: Array<{ bead: Bead; agent: Agent | null }> = []; @@ -2493,19 +2722,20 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.staged} = 0 WHERE ${convoy_metadata.bead_id} = ? `, - [convoyId] + [convoyId], ); - events.insertEvent(this.sql, 'convoy_started', { + events.insertEvent(this.sql, "convoy_started", { payload: { convoy_id: convoyId }, }); await this.armAlarmIfNeeded(); const updatedConvoy = this.getConvoy(convoyId); - if (!updatedConvoy) throw new Error(`Failed to re-fetch convoy after start: ${convoyId}`); + if (!updatedConvoy) + throw new Error(`Failed to re-fetch convoy after start: ${convoyId}`); this.emitEvent({ - event: 'convoy.started', + event: "convoy.started", townId: this.townId, convoyId, }); @@ -2522,10 +2752,10 @@ export class TownDO extends DurableObject { /* sql */ `${CONVOY_JOIN} WHERE ${beads.status} != 'closed' ORDER BY ${beads.created_at} DESC`, - [] + [], ), ]; - return rows.map(row => toConvoy(ConvoyBeadRecord.parse(row))); + return rows.map((row) => toConvoy(ConvoyBeadRecord.parse(row))); } /** @@ -2599,7 +2829,7 @@ export class TownDO extends DurableObject { AND ${bead_dependencies.dependency_type} = 'tracks' ORDER BY ${beads.created_at} ASC `, - [convoyId] + [convoyId], ), ]; @@ -2612,18 +2842,25 @@ export class TownDO extends DurableObject { }); // Get DAG edges (blocks dependencies) between tracked beads - const dependencyEdges = beadOps.getConvoyDependencyEdges(this.sql, convoyId); + const dependencyEdges = beadOps.getConvoyDependencyEdges( + this.sql, + convoyId, + ); return { ...convoy, - beads: trackedRows.map(row => TrackedBeadRow.parse(row)), + beads: trackedRows.map((row) => TrackedBeadRow.parse(row)), dependency_edges: dependencyEdges, }; } private getConvoy(convoyId: string): ConvoyEntry | null { const rows = [ - ...query(this.sql, /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, [convoyId]), + ...query( + this.sql, + /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, + [convoyId], + ), ]; if (rows.length === 0) return null; return toConvoy(ConvoyBeadRecord.parse(rows[0])); @@ -2633,7 +2870,9 @@ export class TownDO extends DurableObject { // Escalations (beads with type='escalation' + escalation_metadata) // ══════════════════════════════════════════════════════════════════ - async acknowledgeEscalation(escalationId: string): Promise { + async acknowledgeEscalation( + escalationId: string, + ): Promise { query( this.sql, /* sql */ ` @@ -2641,39 +2880,41 @@ export class TownDO extends DurableObject { SET ${escalation_metadata.columns.acknowledged} = 1, ${escalation_metadata.columns.acknowledged_at} = ? WHERE ${escalation_metadata.bead_id} = ? AND ${escalation_metadata.acknowledged} = 0 `, - [now(), escalationId] + [now(), escalationId], ); // Acknowledging an escalation also closes it — the mayor has seen // the issue and doesn't need it sitting open in the queue. // Guard with getBead so stale/duplicate acknowledge calls remain // idempotent instead of throwing on a missing bead. const escalationBead = beadOps.getBead(this.sql, escalationId); - if (escalationBead && escalationBead.status !== 'closed') { - beadOps.updateBeadStatus(this.sql, escalationId, 'closed', null); + if (escalationBead && escalationBead.status !== "closed") { + beadOps.updateBeadStatus(this.sql, escalationId, "closed", null); } this.emitEvent({ - event: 'escalation.acknowledged', + event: "escalation.acknowledged", townId: this.townId, beadId: escalationId, }); return this.getEscalation(escalationId); } - async listEscalations(filter?: { acknowledged?: boolean }): Promise { + async listEscalations(filter?: { + acknowledged?: boolean; + }): Promise { const rows = filter?.acknowledged !== undefined ? [ ...query( this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = ? ORDER BY ${beads.created_at} DESC LIMIT 100`, - [filter.acknowledged ? 1 : 0] + [filter.acknowledged ? 1 : 0], ), ] : [ ...query( this.sql, /* sql */ `${ESCALATION_JOIN} ORDER BY ${beads.created_at} DESC LIMIT 100`, - [] + [], ), ]; return EscalationBeadRecord.array().parse(rows).map(toEscalation); @@ -2683,7 +2924,7 @@ export class TownDO extends DurableObject { townId: string; source_rig_id: string; source_agent_id?: string; - severity: 'low' | 'medium' | 'high' | 'critical'; + severity: "low" | "medium" | "high" | "critical"; category?: string; message: string; }): Promise { @@ -2722,21 +2963,25 @@ export class TownDO extends DurableObject { `, [ beadId, - 'escalation', - 'open', + "escalation", + "open", `Escalation: ${input.message.slice(0, 100)}`, input.message, input.source_rig_id, null, null, - input.severity === 'critical' ? 'critical' : input.severity === 'high' ? 'high' : 'medium', - JSON.stringify(['gt:escalation', `severity:${input.severity}`]), + input.severity === "critical" + ? "critical" + : input.severity === "high" + ? "high" + : "medium", + JSON.stringify(["gt:escalation", `severity:${input.severity}`]), JSON.stringify(metadata), input.source_agent_id ?? null, timestamp, timestamp, null, - ] + ], ); // Create escalation_metadata @@ -2749,14 +2994,14 @@ export class TownDO extends DurableObject { ${escalation_metadata.columns.re_escalation_count}, ${escalation_metadata.columns.acknowledged_at} ) VALUES (?, ?, ?, ?, ?, ?) `, - [beadId, input.severity, input.category ?? null, 0, 0, null] + [beadId, input.severity, input.category ?? null, 0, 0, null], ); const escalation = this.getEscalation(beadId); - if (!escalation) throw new Error('Failed to create escalation'); + if (!escalation) throw new Error("Failed to create escalation"); this.emitEvent({ - event: 'escalation.created', + event: "escalation.created", townId: this.townId, rigId: input.source_rig_id, agentId: input.source_agent_id, @@ -2768,7 +3013,7 @@ export class TownDO extends DurableObject { // act on the escalation. Without this, escalation beads sit open // with no assignee and no automated follow-up. patrol.createTriageRequest(this.sql, { - triageType: 'escalation', + triageType: "escalation", agentBeadId: input.source_agent_id ?? null, title: `Escalation (${input.severity}): ${input.message.slice(0, 80)}`, context: { @@ -2780,25 +3025,28 @@ export class TownDO extends DurableObject { source_bead_id: sourceBeadId, }, options: - input.severity === 'low' - ? ['NUDGE', 'CLOSE_BEAD', 'PROVIDE_GUIDANCE'] - : ['ESCALATE_TO_MAYOR', 'RESTART', 'CLOSE_BEAD', 'REASSIGN_BEAD'], + input.severity === "low" + ? ["NUDGE", "CLOSE_BEAD", "PROVIDE_GUIDANCE"] + : ["ESCALATE_TO_MAYOR", "RESTART", "CLOSE_BEAD", "REASSIGN_BEAD"], rigId: input.source_rig_id, }); // Notify mayor directly for medium+ severity (in addition to triage) - if (input.severity !== 'low') { + if (input.severity !== "low") { this.sendMayorMessage( - `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}` - ).catch(err => { - console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); + `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}`, + ).catch((err) => { + console.warn( + `${TOWN_LOG} routeEscalation: failed to notify mayor:`, + err, + ); try { beadOps.logBeadEvent(this.sql, { beadId, agentId: input.source_agent_id ?? null, - eventType: 'notification_failed', + eventType: "notification_failed", metadata: { - target: 'mayor', + target: "mayor", reason: err instanceof Error ? err.message : String(err), severity: input.severity, }, @@ -2806,7 +3054,7 @@ export class TownDO extends DurableObject { } catch (logErr) { console.error( `${TOWN_LOG} routeEscalation: failed to log notification_failed event:`, - logErr + logErr, ); } }); @@ -2817,7 +3065,11 @@ export class TownDO extends DurableObject { private getEscalation(escalationId: string): EscalationEntry | null { const rows = [ - ...query(this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${beads.bead_id} = ?`, [escalationId]), + ...query( + this.sql, + /* sql */ `${ESCALATION_JOIN} WHERE ${beads.bead_id} = ?`, + [escalationId], + ), ]; if (rows.length === 0) return null; return toEscalation(EscalationBeadRecord.parse(rows[0])); @@ -2832,9 +3084,11 @@ export class TownDO extends DurableObject { // After destroy(), deleteAll() wipes storage but may not clear // the alarm (compat date < 2026-02-24). A resurrected alarm // will find no town:id — stop the loop immediately. - const storedId = await this.ctx.storage.get('town:id'); + const storedId = await this.ctx.storage.get("town:id"); if (!storedId) { - console.log(`${TOWN_LOG} alarm: no town:id — town was destroyed, not re-arming`); + console.log( + `${TOWN_LOG} alarm: no town:id — town was destroyed, not re-arming`, + ); await this.ctx.storage.deleteAlarm(); return; } @@ -2879,17 +3133,17 @@ export class TownDO extends DurableObject { FROM ${agent_metadata} WHERE ${agent_metadata.status} IN ('working', 'stalled') `, - [] + [], ), ]); if (workingAgentRows.length > 0) { - const statusChecks = workingAgentRows.map(async row => { + const statusChecks = workingAgentRows.map(async (row) => { try { const containerInfo = await dispatch.checkAgentContainerStatus( this.env, townId, - row.bead_id + row.bead_id, ); events.upsertContainerStatus(this.sql, row.bead_id, { status: containerInfo.status, @@ -2898,7 +3152,7 @@ export class TownDO extends DurableObject { } catch (err) { console.warn( `${TOWN_LOG} alarm: container status check failed for agent=${row.bead_id}`, - err + err, ); } }); @@ -2927,7 +3181,9 @@ export class TownDO extends DurableObject { const pending = events.drainEvents(this.sql); metrics.eventsDrained = pending.length; if (pending.length > 0) { - console.log(`${TOWN_LOG} [reconciler] town=${townId} draining ${pending.length} event(s)`); + console.log( + `${TOWN_LOG} [reconciler] town=${townId} draining ${pending.length} event(s)`, + ); } for (const event of pending) { try { @@ -2936,7 +3192,7 @@ export class TownDO extends DurableObject { } catch (err) { console.error( `${TOWN_LOG} [reconciler] town=${townId} applyEvent failed: event=${event.event_id} type=${event.event_type}`, - err + err, ); // Event stays unprocessed — will be retried on the next alarm tick. // Mark it processed anyway after 3 consecutive failures to prevent @@ -2945,7 +3201,10 @@ export class TownDO extends DurableObject { } } } catch (err) { - console.error(`${TOWN_LOG} [reconciler] town=${townId} event drain failed`, err); + console.error( + `${TOWN_LOG} [reconciler] town=${townId} event drain failed`, + err, + ); Sentry.captureException(err); } @@ -2955,11 +3214,12 @@ export class TownDO extends DurableObject { const actions = reconciler.reconcile(this.sql); metrics.actionsEmitted = actions.length; for (const a of actions) { - metrics.actionsByType[a.type] = (metrics.actionsByType[a.type] ?? 0) + 1; + metrics.actionsByType[a.type] = + (metrics.actionsByType[a.type] ?? 0) + 1; } if (actions.length > 0) { console.log( - `${TOWN_LOG} [reconciler] town=${townId} actions=${actions.length} types=${[...new Set(actions.map(a => a.type))].join(',')}` + `${TOWN_LOG} [reconciler] town=${townId} actions=${actions.length} types=${[...new Set(actions.map((a) => a.type))].join(",")}`, ); } const ctx = this.applyActionCtx; @@ -2970,21 +3230,24 @@ export class TownDO extends DurableObject { } catch (err) { console.error( `${TOWN_LOG} [reconciler] town=${townId} applyAction failed: type=${action.type}`, - err + err, ); } } } catch (err) { - console.error(`${TOWN_LOG} [reconciler] town=${townId} reconcile failed`, err); + console.error( + `${TOWN_LOG} [reconciler] town=${townId} reconcile failed`, + err, + ); Sentry.captureException(err); } // Phase 2: Execute side effects (async, best-effort) metrics.sideEffectsAttempted = sideEffects.length; if (sideEffects.length > 0) { - const results = await Promise.allSettled(sideEffects.map(fn => fn())); + const results = await Promise.allSettled(sideEffects.map((fn) => fn())); for (const r of results) { - if (r.status === 'fulfilled') metrics.sideEffectsSucceeded++; + if (r.status === "fulfilled") metrics.sideEffectsSucceeded++; else metrics.sideEffectsFailed++; } } @@ -2994,12 +3257,22 @@ export class TownDO extends DurableObject { const violations = reconciler.checkInvariants(this.sql); metrics.invariantViolations = violations.length; if (violations.length > 0) { - console.error( - `${TOWN_LOG} [reconciler:invariants] town=${townId} ${violations.length} violation(s): ${JSON.stringify(violations)}` - ); + // Emit as an analytics event for observability dashboards instead + // of console.error (which spams Workers logs every 5s per town). + this.emitEvent({ + event: "reconciler.invariant_violations", + townId, + label: violations + .map((v) => `[${v.invariant}] ${v.message}`) + .join("; "), + value: violations.length, + }); } } catch (err) { - console.warn(`${TOWN_LOG} [reconciler:invariants] town=${townId} check failed`, err); + console.warn( + `${TOWN_LOG} [reconciler:invariants] town=${townId} check failed`, + err, + ); } metrics.wallClockMs = Date.now() - reconcilerStart; @@ -3008,17 +3281,17 @@ export class TownDO extends DurableObject { // ── Phase 3: Housekeeping (independent, all parallelizable) ──── await Promise.allSettled([ - this.deliverPendingMail().catch(err => - console.warn(`${TOWN_LOG} alarm: deliverPendingMail failed`, err) + this.deliverPendingMail().catch((err) => + console.warn(`${TOWN_LOG} alarm: deliverPendingMail failed`, err), ), - this.expireStaleNudges().catch(err => - console.warn(`${TOWN_LOG} alarm: expireStaleNudges failed`, err) + this.expireStaleNudges().catch((err) => + console.warn(`${TOWN_LOG} alarm: expireStaleNudges failed`, err), ), - this.reEscalateStaleEscalations().catch(err => - console.warn(`${TOWN_LOG} alarm: reEscalation failed`, err) + this.reEscalateStaleEscalations().catch((err) => + console.warn(`${TOWN_LOG} alarm: reEscalation failed`, err), ), - this.maybeDispatchTriageAgent().catch(err => - console.warn(`${TOWN_LOG} alarm: maybeDispatchTriageAgent failed`, err) + this.maybeDispatchTriageAgent().catch((err) => + console.warn(`${TOWN_LOG} alarm: maybeDispatchTriageAgent failed`, err), ), // Prune processed reconciler events older than 7 days Promise.resolve().then(() => { @@ -3053,7 +3326,8 @@ export class TownDO extends DurableObject { private async refreshContainerToken(): Promise { const TOKEN_REFRESH_INTERVAL_MS = 60 * 60_000; // 1 hour const now = Date.now(); - if (now - this.lastContainerTokenRefreshAt < TOKEN_REFRESH_INTERVAL_MS) return; + if (now - this.lastContainerTokenRefreshAt < TOKEN_REFRESH_INTERVAL_MS) + return; const townId = this.townId; if (!townId) return; @@ -3073,7 +3347,7 @@ export class TownDO extends DurableObject { private dispatchAgent( agent: Agent, bead: Bead, - options?: { systemPromptOverride?: string } + options?: { systemPromptOverride?: string }, ): Promise { return scheduling.dispatchAgent(this.schedulingCtx, agent, bead, options); } @@ -3099,9 +3373,11 @@ export class TownDO extends DurableObject { // rapid retry loops). Skip dispatch in either case. const triageBatchLike = patrol.TRIAGE_LABEL_LIKE.replace( patrol.TRIAGE_REQUEST_LABEL, - patrol.TRIAGE_BATCH_LABEL + patrol.TRIAGE_BATCH_LABEL, ); - const cooldownCutoff = new Date(Date.now() - scheduling.DISPATCH_COOLDOWN_MS).toISOString(); + const cooldownCutoff = new Date( + Date.now() - scheduling.DISPATCH_COOLDOWN_MS, + ).toISOString(); const existingBatch = [ ...query( this.sql, @@ -3116,12 +3392,12 @@ export class TownDO extends DurableObject { ) LIMIT 1 `, - [triageBatchLike, cooldownCutoff] + [triageBatchLike, cooldownCutoff], ), ]; if (existingBatch.length > 0) { console.log( - `${TOWN_LOG} maybeDispatchTriageAgent: triage batch bead active or in cooldown, skipping (${pendingCount} pending)` + `${TOWN_LOG} maybeDispatchTriageAgent: triage batch bead active or in cooldown, skipping (${pendingCount} pending)`, ); return; } @@ -3130,19 +3406,23 @@ export class TownDO extends DurableObject { // leaked phantom issue beads on early-return paths. const rigList = rigs.listRigs(this.sql); if (rigList.length === 0) { - console.warn(`${TOWN_LOG} maybeDispatchTriageAgent: no rigs available, skipping`); + console.warn( + `${TOWN_LOG} maybeDispatchTriageAgent: no rigs available, skipping`, + ); return; } const rigId = rigList[0].id; const rigConfig = await this.getRigConfig(rigId); if (!rigConfig) { - console.warn(`${TOWN_LOG} maybeDispatchTriageAgent: no rig config for rig=${rigId}`); + console.warn( + `${TOWN_LOG} maybeDispatchTriageAgent: no rig config for rig=${rigId}`, + ); return; } console.log( - `${TOWN_LOG} maybeDispatchTriageAgent: ${pendingCount} pending triage request(s), dispatching agent` + `${TOWN_LOG} maybeDispatchTriageAgent: ${pendingCount} pending triage request(s), dispatching agent`, ); const townConfig = await this.getTownConfig(); @@ -3150,54 +3430,71 @@ export class TownDO extends DurableObject { // Build the triage prompt from pending requests const pendingRequests = patrol.listPendingTriageRequests(this.sql); - const { buildTriageSystemPrompt } = await import('../prompts/triage-system.prompt'); + const { buildTriageSystemPrompt } = + await import("../prompts/triage-system.prompt"); const systemPrompt = buildTriageSystemPrompt(pendingRequests); // Only now create the synthetic bead — preconditions are verified. const triageBead = beadOps.createBead(this.sql, { - type: 'issue', + type: "issue", title: `Triage batch: ${pendingCount} request(s)`, - body: 'Process all pending triage request beads and resolve each one.', - priority: 'high', + body: "Process all pending triage request beads and resolve each one.", + priority: "high", labels: [patrol.TRIAGE_BATCH_LABEL], - created_by: 'patrol', + created_by: "patrol", }); - const triageAgent = agents.getOrCreateAgent(this.sql, 'polecat', rigId, this.townId); + const triageAgent = agents.getOrCreateAgent( + this.sql, + "polecat", + rigId, + this.townId, + ); agents.hookBead(this.sql, triageAgent.id, triageBead.bead_id); - const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { - townId: this.townId, - rigId, - userId: rigConfig.userId, - agentId: triageAgent.id, - agentName: triageAgent.name, - role: 'polecat', - identity: triageAgent.identity, - beadId: triageBead.bead_id, - beadTitle: triageBead.title, - beadBody: triageBead.body ?? '', - checkpoint: null, - gitUrl: rigConfig.gitUrl, - defaultBranch: rigConfig.defaultBranch, - kilocodeToken, - townConfig, - systemPromptOverride: systemPrompt, - platformIntegrationId: rigConfig.platformIntegrationId, - lightweight: true, - }); + const started = await dispatch.startAgentInContainer( + this.env, + this.ctx.storage, + { + townId: this.townId, + rigId, + userId: rigConfig.userId, + agentId: triageAgent.id, + agentName: triageAgent.name, + role: "polecat", + identity: triageAgent.identity, + beadId: triageBead.bead_id, + beadTitle: triageBead.title, + beadBody: triageBead.body ?? "", + checkpoint: null, + gitUrl: rigConfig.gitUrl, + defaultBranch: rigConfig.defaultBranch, + kilocodeToken, + townConfig, + systemPromptOverride: systemPrompt, + platformIntegrationId: rigConfig.platformIntegrationId, + lightweight: true, + }, + ); if (started) { // Mark the agent as working so the duplicate-guard on the next // alarm tick sees it and skips dispatch. - agents.updateAgentStatus(this.sql, triageAgent.id, 'working'); + agents.updateAgentStatus(this.sql, triageAgent.id, "working"); } else { agents.unhookBead(this.sql, triageAgent.id); // Failing the batch bead triggers cooldown: the guard at the top of // this method skips dispatch while a failed batch bead's updated_at // is within DISPATCH_COOLDOWN_MS. - beadOps.updateBeadStatus(this.sql, triageBead.bead_id, 'failed', triageAgent.id); - console.error(`${TOWN_LOG} maybeDispatchTriageAgent: triage agent failed to start`); + beadOps.updateBeadStatus( + this.sql, + triageBead.bead_id, + "failed", + triageAgent.id, + ); + console.error( + `${TOWN_LOG} maybeDispatchTriageAgent: triage agent failed to start`, + ); } } @@ -3213,41 +3510,50 @@ export class TownDO extends DurableObject { if (pendingByAgent.size === 0) return; console.log( - `${TOWN_LOG} deliverPendingMail: ${pendingByAgent.size} agent(s) with pending mail` + `${TOWN_LOG} deliverPendingMail: ${pendingByAgent.size} agent(s) with pending mail`, ); - const deliveries = [...pendingByAgent.entries()].map(async ([agentId, messages]) => { - const lines = messages.map(m => `[MAIL from ${m.from_agent_id}] ${m.subject}\n${m.body}`); - const prompt = `You have ${messages.length} new mail message(s):\n\n${lines.join('\n\n---\n\n')}`; + const deliveries = [...pendingByAgent.entries()].map( + async ([agentId, messages]) => { + const lines = messages.map( + (m) => `[MAIL from ${m.from_agent_id}] ${m.subject}\n${m.body}`, + ); + const prompt = `You have ${messages.length} new mail message(s):\n\n${lines.join("\n\n---\n\n")}`; - const sent = await dispatch.sendMessageToAgent(this.env, this.townId, agentId, prompt); + const sent = await dispatch.sendMessageToAgent( + this.env, + this.townId, + agentId, + prompt, + ); - if (sent) { - // Mark delivered only after the container accepted the message - mail.readAndDeliverMail(this.sql, agentId); - if ( - messages.some( - m => - m.subject === 'TRIAGE_NUDGE' || - m.subject === 'GUPP_ESCALATION' || - m.subject === 'GUPP_CHECK' - ) - ) { - this.emitEvent({ - event: 'nudge.delivered', - townId: this.townId, - agentId, - }); + if (sent) { + // Mark delivered only after the container accepted the message + mail.readAndDeliverMail(this.sql, agentId); + if ( + messages.some( + (m) => + m.subject === "TRIAGE_NUDGE" || + m.subject === "GUPP_ESCALATION" || + m.subject === "GUPP_CHECK", + ) + ) { + this.emitEvent({ + event: "nudge.delivered", + townId: this.townId, + agentId, + }); + } + console.log( + `${TOWN_LOG} deliverPendingMail: delivered ${messages.length} message(s) to agent=${agentId}`, + ); + } else { + console.warn( + `${TOWN_LOG} deliverPendingMail: failed to push mail to agent=${agentId}, will retry next tick`, + ); } - console.log( - `${TOWN_LOG} deliverPendingMail: delivered ${messages.length} message(s) to agent=${agentId}` - ); - } else { - console.warn( - `${TOWN_LOG} deliverPendingMail: failed to push mail to agent=${agentId}, will retry next tick` - ); - } - }); + }, + ); await Promise.allSettled(deliveries); } @@ -3258,15 +3564,19 @@ export class TownDO extends DurableObject { */ private async checkPRStatus( prUrl: string, - townConfig: TownConfig - ): Promise<'open' | 'merged' | 'closed' | null> { + townConfig: TownConfig, + ): Promise<"open" | "merged" | "closed" | null> { // GitHub PR URL format: https://github.com/{owner}/{repo}/pull/{number} - const ghMatch = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + const ghMatch = prUrl.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/, + ); if (ghMatch) { const [, owner, repo, numberStr] = ghMatch; const token = townConfig.git_auth.github_token; if (!token) { - console.warn(`${TOWN_LOG} checkPRStatus: no github_token configured, cannot poll ${prUrl}`); + console.warn( + `${TOWN_LOG} checkPRStatus: no github_token configured, cannot poll ${prUrl}`, + ); return null; } @@ -3275,14 +3585,14 @@ export class TownDO extends DurableObject { { headers: { Authorization: `token ${token}`, - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'Gastown-Refinery/1.0', + Accept: "application/vnd.github.v3+json", + "User-Agent": "Gastown-Refinery/1.0", }, - } + }, ); if (!response.ok) { console.warn( - `${TOWN_LOG} checkPRStatus: GitHub API returned ${response.status} for ${prUrl}` + `${TOWN_LOG} checkPRStatus: GitHub API returned ${response.status} for ${prUrl}`, ); return null; } @@ -3292,18 +3602,22 @@ export class TownDO extends DurableObject { const data = GitHubPRStatusSchema.safeParse(json); if (!data.success) return null; - if (data.data.merged) return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.merged) return "merged"; + if (data.data.state === "closed") return "closed"; + return "open"; } // GitLab MR URL format: https://{host}/{path}/-/merge_requests/{iid} - const glMatch = prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); + const glMatch = prUrl.match( + /^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/, + ); if (glMatch) { const [, instanceUrl, projectPath, iidStr] = glMatch; const token = townConfig.git_auth.gitlab_token; if (!token) { - console.warn(`${TOWN_LOG} checkPRStatus: no gitlab_token configured, cannot poll ${prUrl}`); + console.warn( + `${TOWN_LOG} checkPRStatus: no gitlab_token configured, cannot poll ${prUrl}`, + ); return null; } @@ -3313,9 +3627,9 @@ export class TownDO extends DurableObject { const configuredHost = townConfig.git_auth.gitlab_instance_url ? new URL(townConfig.git_auth.gitlab_instance_url).hostname : null; - if (prHost !== 'gitlab.com' && prHost !== configuredHost) { + if (prHost !== "gitlab.com" && prHost !== configuredHost) { console.warn( - `${TOWN_LOG} checkPRStatus: refusing to send gitlab_token to unknown host: ${prHost}` + `${TOWN_LOG} checkPRStatus: refusing to send gitlab_token to unknown host: ${prHost}`, ); return null; } @@ -3324,12 +3638,12 @@ export class TownDO extends DurableObject { const response = await fetch( `${instanceUrl}/api/v4/projects/${encodedPath}/merge_requests/${iidStr}`, { - headers: { 'PRIVATE-TOKEN': token }, - } + headers: { "PRIVATE-TOKEN": token }, + }, ); if (!response.ok) { console.warn( - `${TOWN_LOG} checkPRStatus: GitLab API returned ${response.status} for ${prUrl}` + `${TOWN_LOG} checkPRStatus: GitLab API returned ${response.status} for ${prUrl}`, ); return null; } @@ -3339,12 +3653,14 @@ export class TownDO extends DurableObject { const data = GitLabMRStatusSchema.safeParse(glJson); if (!data.success) return null; - if (data.data.state === 'merged') return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.state === "merged") return "merged"; + if (data.data.state === "closed") return "closed"; + return "open"; } - console.warn(`${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`); + console.warn( + `${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`, + ); return null; } @@ -3356,14 +3672,15 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = 0 AND ${escalation_metadata.re_escalation_count} < ?`, - [MAX_RE_ESCALATIONS] + [MAX_RE_ESCALATIONS], ), - ].map(r => toEscalation(EscalationBeadRecord.parse(r))); + ].map((r) => toEscalation(EscalationBeadRecord.parse(r))); const nowMs = Date.now(); for (const esc of candidates) { const ageMs = nowMs - new Date(esc.created_at).getTime(); - const requiredAgeMs = (esc.re_escalation_count + 1) * STALE_ESCALATION_THRESHOLD_MS; + const requiredAgeMs = + (esc.re_escalation_count + 1) * STALE_ESCALATION_THRESHOLD_MS; if (ageMs < requiredAgeMs) continue; const currentIdx = SEVERITY_ORDER.indexOf(esc.severity); @@ -3378,21 +3695,24 @@ export class TownDO extends DurableObject { ${escalation_metadata.columns.re_escalation_count} = ${escalation_metadata.columns.re_escalation_count} + 1 WHERE ${escalation_metadata.bead_id} = ? `, - [newSeverity, esc.id] + [newSeverity, esc.id], ); - if (newSeverity !== 'low') { + if (newSeverity !== "low") { this.sendMayorMessage( - `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}` - ).catch(err => { - console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); + `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}`, + ).catch((err) => { + console.warn( + `${TOWN_LOG} re-escalation: failed to notify mayor:`, + err, + ); try { beadOps.logBeadEvent(this.sql, { beadId: esc.id, agentId: null, - eventType: 'notification_failed', + eventType: "notification_failed", metadata: { - target: 'mayor', + target: "mayor", reason: err instanceof Error ? err.message : String(err), severity: newSeverity, re_escalation: true, @@ -3401,7 +3721,7 @@ export class TownDO extends DurableObject { } catch (logErr) { console.error( `${TOWN_LOG} re-escalation: failed to log notification_failed event:`, - logErr + logErr, ); } }); @@ -3429,7 +3749,7 @@ export class TownDO extends DurableObject { try { const container = getTownContainerStub(this.env, townId); - await container.fetch('http://container/health', { + await container.fetch("http://container/health", { signal: AbortSignal.timeout(5_000), }); } catch { @@ -3442,7 +3762,7 @@ export class TownDO extends DurableObject { private async armAlarmIfNeeded(): Promise { // Don't resurrect the alarm on a destroyed DO. After destroy(), // town:id is wiped — if it's missing, the town was deleted. - const storedId = await this.ctx.storage.get('town:id'); + const storedId = await this.ctx.storage.get("town:id"); if (!storedId) return; const current = await this.ctx.storage.getAlarm(); @@ -3474,7 +3794,9 @@ export class TownDO extends DurableObject { // Re-arm if missing — this is the whole point of the watchdog if (!alarmSet) { - console.warn(`${TOWN_LOG} healthCheck: alarm not set for town=${townId}, re-arming`); + console.warn( + `${TOWN_LOG} healthCheck: alarm not set for town=${townId}, re-arming`, + ); await this.ctx.storage.setAlarm(Date.now() + ACTIVE_ALARM_INTERVAL_MS); } @@ -3483,9 +3805,9 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `SELECT COUNT(*) AS cnt FROM ${agent_metadata} WHERE ${agent_metadata.status} IN ('working', 'stalled')`, - [] + [], ), - ][0]?.cnt ?? 0 + ][0]?.cnt ?? 0, ); const pendingBeads = Number( @@ -3493,9 +3815,9 @@ export class TownDO extends DurableObject { ...query( this.sql, /* 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 + ][0]?.cnt ?? 0, ); return { townId, alarmSet, activeAgents, pendingBeads }; @@ -3540,7 +3862,9 @@ export class TownDO extends DurableObject { }> { const currentAlarm = await this.ctx.storage.getAlarm(); const active = this.hasActiveWork(); - const intervalMs = active ? ACTIVE_ALARM_INTERVAL_MS : IDLE_ALARM_INTERVAL_MS; + const intervalMs = active + ? ACTIVE_ALARM_INTERVAL_MS + : IDLE_ALARM_INTERVAL_MS; // Agent counts by status const agentRows = [ @@ -3551,7 +3875,7 @@ export class TownDO extends DurableObject { FROM ${agent_metadata} GROUP BY ${agent_metadata.status} `, - [] + [], ), ]; const agentCounts = { working: 0, idle: 0, stalled: 0, dead: 0, total: 0 }; @@ -3572,17 +3896,23 @@ export class TownDO extends DurableObject { WHERE ${beads.type} NOT IN ('agent', 'message') GROUP BY ${beads.status} `, - [] + [], ), ]; - const beadCounts = { open: 0, inProgress: 0, inReview: 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; + 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; } // Triage request count (issue beads with gt:triage-request label) @@ -3599,9 +3929,9 @@ export class TownDO extends DurableObject { AND ${beads.title} = 'GUPP_CHECK' AND ${beads.status} = 'open' `, - [] + [], ), - ][0]?.cnt ?? 0 + ][0]?.cnt ?? 0, ); const guppEscalations = Number( @@ -3614,9 +3944,9 @@ export class TownDO extends DurableObject { AND ${beads.title} = 'GUPP_ESCALATION' AND ${beads.status} = 'open' `, - [] + [], ), - ][0]?.cnt ?? 0 + ][0]?.cnt ?? 0, ); const stalledAgents = agentCounts.stalled; @@ -3638,9 +3968,9 @@ export class TownDO extends DurableObject { OR ${agent_metadata.last_activity_at} < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes') ) `, - [] + [], ), - ][0]?.cnt ?? 0 + ][0]?.cnt ?? 0, ); // Recent bead events (last 20) for the activity feed @@ -3655,11 +3985,11 @@ export class TownDO extends DurableObject { ORDER BY be.created_at DESC LIMIT 20 `, - [] + [], ), ]; - const recentEvents = recentRows.map(row => ({ + const recentEvents = recentRows.map((row) => ({ time: `${row.created_at as string}`, type: `${row.event_type as string}`, message: formatEventMessage(row), @@ -3667,9 +3997,11 @@ export class TownDO extends DurableObject { return { alarm: { - nextFireAt: currentAlarm ? new Date(Number(currentAlarm)).toISOString() : null, + nextFireAt: currentAlarm + ? new Date(Number(currentAlarm)).toISOString() + : null, intervalMs, - intervalLabel: active ? 'active (5s)' : 'idle (60s)', + intervalLabel: active ? "active (5s)" : "idle (60s)", }, agents: agentCounts, beads: beadCounts, @@ -3701,7 +4033,7 @@ export class TownDO extends DurableObject { AND ${beads.type} != 'agent' ORDER BY ${beads.type}, ${beads.status} `, - [] + [], ), ]; } @@ -3720,7 +4052,7 @@ export class TownDO extends DurableObject { ${agent_metadata.last_activity_at} FROM ${agent_metadata} `, - [] + [], ), ]; } @@ -3732,7 +4064,7 @@ export class TownDO extends DurableObject { try { const allAgents = agents.listAgents(this.sql); await Promise.allSettled( - allAgents.map(agent => getAgentDOStub(this.env, agent.id).destroy()) + allAgents.map((agent) => getAgentDOStub(this.env, agent.id).destroy()), ); } catch (err) { console.warn(`${TOWN_LOG} destroy: agent cleanup failed`, err); diff --git a/cloudflare-gastown/src/dos/town/reconciler.ts b/cloudflare-gastown/src/dos/town/reconciler.ts index d781f5bc8..40df14393 100644 --- a/cloudflare-gastown/src/dos/town/reconciler.ts +++ b/cloudflare-gastown/src/dos/town/reconciler.ts @@ -11,30 +11,39 @@ * See reconciliation-spec.md §5.3. */ -import { z } from 'zod'; -import { beads, BeadRecord } from '../../db/tables/beads.table'; -import { agent_metadata, AgentMetadataRecord } from '../../db/tables/agent-metadata.table'; -import { review_metadata, ReviewMetadataRecord } from '../../db/tables/review-metadata.table'; -import { convoy_metadata, ConvoyMetadataRecord } from '../../db/tables/convoy-metadata.table'; -import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; -import { agent_nudges } from '../../db/tables/agent-nudges.table'; -import { query } from '../../util/query.util'; +import { z } from "zod"; +import { beads, BeadRecord } from "../../db/tables/beads.table"; +import { + agent_metadata, + AgentMetadataRecord, +} from "../../db/tables/agent-metadata.table"; +import { + review_metadata, + ReviewMetadataRecord, +} from "../../db/tables/review-metadata.table"; +import { + convoy_metadata, + ConvoyMetadataRecord, +} from "../../db/tables/convoy-metadata.table"; +import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; +import { agent_nudges } from "../../db/tables/agent-nudges.table"; +import { query } from "../../util/query.util"; import { GUPP_WARN_MS, GUPP_ESCALATE_MS, GUPP_FORCE_STOP_MS, AGENT_GC_RETENTION_MS, TRIAGE_LABEL_LIKE, -} from './patrol'; -import { DISPATCH_COOLDOWN_MS, MAX_DISPATCH_ATTEMPTS } from './scheduling'; -import * as reviewQueue from './review-queue'; -import * as agents from './agents'; -import * as beadOps from './beads'; -import { getRig } from './rigs'; -import type { Action } from './actions'; -import type { TownEventRecord } from '../../db/tables/town-events.table'; +} from "./patrol"; +import { DISPATCH_COOLDOWN_MS, MAX_DISPATCH_ATTEMPTS } from "./scheduling"; +import * as reviewQueue from "./review-queue"; +import * as agents from "./agents"; +import * as beadOps from "./beads"; +import { getRig } from "./rigs"; +import type { Action } from "./actions"; +import type { TownEventRecord } from "../../db/tables/town-events.table"; -const LOG = '[reconciler]'; +const LOG = "[reconciler]"; // ── Timeouts (from spec §7) ───────────────────────────────────────── @@ -137,71 +146,76 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { const payload = event.payload; switch (event.event_type) { - case 'agent_done': { + case "agent_done": { if (!event.agent_id) { console.warn(`${LOG} applyEvent: agent_done missing agent_id`); return; } - const branch = typeof payload.branch === 'string' ? payload.branch : ''; - const pr_url = typeof payload.pr_url === 'string' ? payload.pr_url : undefined; - const summary = typeof payload.summary === 'string' ? payload.summary : undefined; + const branch = typeof payload.branch === "string" ? payload.branch : ""; + const pr_url = + typeof payload.pr_url === "string" ? payload.pr_url : undefined; + const summary = + typeof payload.summary === "string" ? payload.summary : undefined; reviewQueue.agentDone(sql, event.agent_id, { branch, pr_url, summary }); return; } - case 'agent_completed': { + case "agent_completed": { if (!event.agent_id) { console.warn(`${LOG} applyEvent: agent_completed missing agent_id`); return; } const status = - payload.status === 'completed' || payload.status === 'failed' ? payload.status : 'failed'; - const reason = typeof payload.reason === 'string' ? payload.reason : undefined; + payload.status === "completed" || payload.status === "failed" + ? payload.status + : "failed"; + const reason = + typeof payload.reason === "string" ? payload.reason : undefined; reviewQueue.agentCompleted(sql, event.agent_id, { status, reason }); return; } - case 'pr_status_changed': { + case "pr_status_changed": { if (!event.bead_id) { console.warn(`${LOG} applyEvent: pr_status_changed missing bead_id`); return; } const pr_state = payload.pr_state; - if (pr_state === 'merged') { + if (pr_state === "merged") { reviewQueue.completeReviewWithResult(sql, { entry_id: event.bead_id, - status: 'merged', - message: 'PR merged (detected by polling)', + status: "merged", + message: "PR merged (detected by polling)", }); - } else if (pr_state === 'closed') { + } else if (pr_state === "closed") { reviewQueue.completeReviewWithResult(sql, { entry_id: event.bead_id, - status: 'failed', - message: 'PR closed without merge', + status: "failed", + message: "PR closed without merge", }); } return; } - case 'bead_created': { + case "bead_created": { // No state change needed — bead already exists in DB. // Reconciler will pick it up as unassigned on next pass. return; } - case 'bead_cancelled': { + case "bead_cancelled": { if (!event.bead_id) { console.warn(`${LOG} applyEvent: bead_cancelled missing bead_id`); return; } const cancelStatus = - payload.cancel_status === 'closed' || payload.cancel_status === 'failed' + payload.cancel_status === "closed" || payload.cancel_status === "failed" ? payload.cancel_status - : 'failed'; + : "failed"; - beadOps.updateBeadStatus(sql, event.bead_id, cancelStatus, 'system'); + beadOps.updateBeadStatus(sql, event.bead_id, cancelStatus, "system"); // Unhook any agent hooked to this bead const hookedAgentRows = z @@ -215,7 +229,7 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { FROM ${agent_metadata} WHERE ${agent_metadata.current_hook_bead_id} = ? `, - [event.bead_id] + [event.bead_id], ), ]); for (const row of hookedAgentRows) { @@ -224,8 +238,9 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { return; } - case 'convoy_started': { - const convoyId = typeof payload.convoy_id === 'string' ? payload.convoy_id : null; + case "convoy_started": { + const convoyId = + typeof payload.convoy_id === "string" ? payload.convoy_id : null; if (!convoyId) { console.warn(`${LOG} applyEvent: convoy_started missing convoy_id`); return; @@ -237,12 +252,12 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { SET ${convoy_metadata.columns.staged} = 0 WHERE ${convoy_metadata.columns.bead_id} = ? `, - [convoyId] + [convoyId], ); return; } - case 'container_status': { + case "container_status": { if (!event.agent_id) return; const containerStatus = payload.status as string; @@ -256,49 +271,52 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { // The 3-minute grace period covers the 60s HTTP timeout plus // typical cold start time (git clone + worktree). Truly dead // agents are caught by reconcileAgents after 90s of no heartbeats. - if (containerStatus === 'not_found' && agent.last_activity_at) { - const ageSec = (Date.now() - new Date(agent.last_activity_at).getTime()) / 1000; + if (containerStatus === "not_found" && agent.last_activity_at) { + const ageSec = + (Date.now() - new Date(agent.last_activity_at).getTime()) / 1000; if (ageSec < 180) return; // 3-minute grace for cold starts } if ( - (agent.status === 'working' || agent.status === 'stalled') && - (containerStatus === 'exited' || containerStatus === 'not_found') + (agent.status === "working" || agent.status === "stalled") && + (containerStatus === "exited" || containerStatus === "not_found") ) { - if (agent.role === 'refinery') { + if (agent.role === "refinery") { // Check if gt_done already completed the MR if (agent.current_hook_bead_id) { const mr = beadOps.getBead(sql, agent.current_hook_bead_id); - if (mr && (mr.status === 'closed' || mr.status === 'failed')) { + if (mr && (mr.status === "closed" || mr.status === "failed")) { // MR already terminal — clean up the refinery agents.unhookBead(sql, event.agent_id); - agents.updateAgentStatus(sql, event.agent_id, 'idle'); + agents.updateAgentStatus(sql, event.agent_id, "idle"); agents.writeCheckpoint(sql, event.agent_id, null); } else { // Refinery died without completing — set idle, keep hook. // reconcileReviewQueue Rule 6 will retry dispatch. - agents.updateAgentStatus(sql, event.agent_id, 'idle'); + agents.updateAgentStatus(sql, event.agent_id, "idle"); } } else { - agents.updateAgentStatus(sql, event.agent_id, 'idle'); + agents.updateAgentStatus(sql, event.agent_id, "idle"); } } else { // Non-refinery died — set idle. Bead stays in_progress. // reconcileBeads Rule 3 will reset it to open after 5 min. - agents.updateAgentStatus(sql, event.agent_id, 'idle'); + agents.updateAgentStatus(sql, event.agent_id, "idle"); } } return; } - case 'nudge_timeout': { + case "nudge_timeout": { // GUPP violations are handled by reconcileGUPP on the next pass. // The event just records the fact for audit trail. return; } default: { - console.warn(`${LOG} applyEvent: unknown event type: ${event.event_type}`); + console.warn( + `${LOG} applyEvent: unknown event type: ${event.event_type}`, + ); } } } @@ -346,7 +364,7 @@ export function reconcileAgents(sql: SqlStorage): Action[] { LEFT JOIN ${beads} b ON b.${beads.columns.bead_id} = ${agent_metadata.bead_id} WHERE ${agent_metadata.status} = 'working' `, - [] + [], ), ]); @@ -354,19 +372,19 @@ export function reconcileAgents(sql: SqlStorage): Action[] { if (!agent.last_activity_at) { // No heartbeat ever received — container may have failed to start actions.push({ - type: 'transition_agent', + type: "transition_agent", agent_id: agent.bead_id, - from: 'working', - to: 'idle', - reason: 'no heartbeat received since dispatch', + from: "working", + to: "idle", + reason: "no heartbeat received since dispatch", }); } else if (staleMs(agent.last_activity_at, 90_000)) { actions.push({ - type: 'transition_agent', + type: "transition_agent", agent_id: agent.bead_id, - from: 'working', - to: 'idle', - reason: 'heartbeat lost (3 missed cycles)', + from: "working", + to: "idle", + reason: "heartbeat lost (3 missed cycles)", }); } } @@ -386,7 +404,7 @@ export function reconcileAgents(sql: SqlStorage): Action[] { WHERE ${agent_metadata.status} = 'idle' AND ${agent_metadata.current_hook_bead_id} IS NOT NULL `, - [] + [], ), ]); @@ -404,33 +422,33 @@ export function reconcileAgents(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [agent.current_hook_bead_id] + [agent.current_hook_bead_id], ), ]); if (hookedRows.length === 0) { // Hooked bead doesn't exist — stale reference actions.push({ - type: 'unhook_agent', + type: "unhook_agent", agent_id: agent.bead_id, - reason: 'hooked bead does not exist', + reason: "hooked bead does not exist", }); actions.push({ - type: 'clear_agent_checkpoint', + type: "clear_agent_checkpoint", agent_id: agent.bead_id, }); continue; } const hookedStatus = hookedRows[0].status; - if (hookedStatus === 'closed' || hookedStatus === 'failed') { + if (hookedStatus === "closed" || hookedStatus === "failed") { actions.push({ - type: 'unhook_agent', + type: "unhook_agent", agent_id: agent.bead_id, - reason: 'hooked bead is terminal', + reason: "hooked bead is terminal", }); actions.push({ - type: 'clear_agent_checkpoint', + type: "clear_agent_checkpoint", agent_id: agent.bead_id, }); } @@ -478,7 +496,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND cm.${convoy_metadata.columns.staged} = 1 ) `, - [TRIAGE_LABEL_LIKE] + [TRIAGE_LABEL_LIKE], ), ]); @@ -488,8 +506,8 @@ export function reconcileBeads(sql: SqlStorage): Action[] { // that a hook_agent + dispatch_agent is needed. // The action includes rig_id so Phase 3's applyAction can resolve the agent. actions.push({ - type: 'dispatch_agent', - agent_id: '', // resolved at apply time + type: "dispatch_agent", + agent_id: "", // resolved at apply time bead_id: bead.bead_id, rig_id: bead.rig_id, }); @@ -511,7 +529,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND ${agent_metadata.current_hook_bead_id} IS NOT NULL AND ${agent_metadata.columns.role} != 'refinery' `, - [] + [], ), ]); @@ -524,17 +542,17 @@ export function reconcileBeads(sql: SqlStorage): Action[] { // Check max dispatch attempts if (agent.dispatch_attempts >= MAX_DISPATCH_ATTEMPTS) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: agent.current_hook_bead_id, from: null, - to: 'failed', - reason: 'max dispatch attempts exceeded', - actor: 'system', + to: "failed", + reason: "max dispatch attempts exceeded", + actor: "system", }); actions.push({ - type: 'unhook_agent', + type: "unhook_agent", agent_id: agent.bead_id, - reason: 'max dispatch attempts', + reason: "max dispatch attempts", }); continue; } @@ -551,13 +569,13 @@ export function reconcileBeads(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [agent.current_hook_bead_id] + [agent.current_hook_bead_id], ), ]); if (hookedRows.length === 0) continue; const hooked = hookedRows[0]; - if (hooked.status !== 'open') continue; + if (hooked.status !== "open") continue; // Check blockers const blockerCount = z @@ -574,17 +592,17 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' AND blocker.${beads.columns.status} NOT IN ('closed', 'failed') `, - [agent.current_hook_bead_id] + [agent.current_hook_bead_id], ), ]); if (blockerCount[0]?.cnt > 0) continue; actions.push({ - type: 'dispatch_agent', + type: "dispatch_agent", agent_id: agent.bead_id, bead_id: agent.current_hook_bead_id, - rig_id: hooked.rig_id ?? agent.rig_id ?? '', + rig_id: hooked.rig_id ?? agent.rig_id ?? "", }); } @@ -603,7 +621,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'issue' AND b.${beads.columns.status} = 'in_progress' `, - [] + [], ), ]); @@ -626,25 +644,25 @@ export function reconcileBeads(sql: SqlStorage): Action[] { WHERE ${agent_metadata.current_hook_bead_id} = ? AND ( ${agent_metadata.status} IN ('working', 'stalled') - OR ${agent_metadata.last_activity_at} > datetime('now', '-90 seconds') + OR ${agent_metadata.last_activity_at} > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 seconds') ) `, - [bead.bead_id] + [bead.bead_id], ), ]); if (hookedAgent.length > 0) continue; actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: bead.bead_id, - from: 'in_progress', - to: 'open', - reason: 'agent lost', - actor: 'system', + from: "in_progress", + to: "open", + reason: "agent lost", + actor: "system", }); actions.push({ - type: 'clear_bead_assignee', + type: "clear_bead_assignee", bead_id: bead.bead_id, }); } @@ -664,7 +682,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'issue' AND b.${beads.columns.status} = 'in_review' `, - [] + [], ), ]); @@ -686,36 +704,38 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND mr.${beads.columns.type} = 'merge_request' `, - [bead.bead_id] + [bead.bead_id], ), ]); if (mrBeads.length === 0) continue; - const allTerminal = mrBeads.every(mr => mr.status === 'closed' || mr.status === 'failed'); + const allTerminal = mrBeads.every( + (mr) => mr.status === "closed" || mr.status === "failed", + ); if (!allTerminal) continue; - const anyMerged = mrBeads.some(mr => mr.status === 'closed'); + const anyMerged = mrBeads.some((mr) => mr.status === "closed"); if (anyMerged) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: bead.bead_id, - from: 'in_review', - to: 'closed', - reason: 'MR merged (reconciler safety net)', - actor: 'system', + from: "in_review", + to: "closed", + reason: "MR merged (reconciler safety net)", + actor: "system", }); } else { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: bead.bead_id, - from: 'in_review', - to: 'open', - reason: 'all reviews failed', - actor: 'system', + from: "in_review", + to: "open", + reason: "all reviews failed", + actor: "system", }); actions.push({ - type: 'clear_bead_assignee', + type: "clear_bead_assignee", bead_id: bead.bead_id, }); } @@ -746,15 +766,15 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'merge_request' AND b.${beads.columns.status} IN ('open', 'in_progress') `, - [] + [], ), ]); for (const mr of mrBeads) { // Rule 1: PR-strategy MR beads in_progress need polling - if (mr.status === 'in_progress' && mr.pr_url) { + if (mr.status === "in_progress" && mr.pr_url) { actions.push({ - type: 'poll_pr', + type: "poll_pr", bead_id: mr.bead_id, pr_url: mr.pr_url, }); @@ -764,7 +784,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Skip MR beads with unresolved rework blockers — they're waiting for // a polecat to finish rework, which is a normal in-flight state. if ( - mr.status === 'in_progress' && + mr.status === "in_progress" && !mr.pr_url && staleMs(mr.updated_at, STUCK_REVIEW_TIMEOUT_MS) ) { @@ -772,20 +792,20 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { const workingAgent = hasWorkingAgentHooked(sql, mr.bead_id); if (!workingAgent) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: mr.bead_id, - from: 'in_progress', - to: 'open', - reason: 'stuck review, no working agent', - actor: 'system', + from: "in_progress", + to: "open", + reason: "stuck review, no working agent", + actor: "system", }); // Unhook any idle agent still pointing at this MR const idleAgent = getIdleAgentHookedTo(sql, mr.bead_id); if (idleAgent) { actions.push({ - type: 'unhook_agent', + type: "unhook_agent", agent_id: idleAgent, - reason: 'stuck review cleanup', + reason: "stuck review cleanup", }); } } @@ -794,7 +814,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Rule 3: Abandoned MR beads in_progress, no PR, no agent hooked, stale >2min // Skip MR beads with rework blockers (same reasoning as Rule 2). if ( - mr.status === 'in_progress' && + mr.status === "in_progress" && !mr.pr_url && staleMs(mr.updated_at, ABANDONED_MR_TIMEOUT_MS) ) { @@ -802,12 +822,12 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { const anyAgent = hasAnyAgentHooked(sql, mr.bead_id); if (!anyAgent) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: mr.bead_id, - from: 'in_progress', - to: 'open', - reason: 'abandoned, no agent hooked', - actor: 'system', + from: "in_progress", + to: "open", + reason: "abandoned, no agent hooked", + actor: "system", }); } } @@ -815,19 +835,19 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Rule 4: PR-strategy MR beads orphaned (refinery dispatched then died, stale >30min) // Only in_progress — open beads are just waiting for the refinery to pop them. if ( - mr.status === 'in_progress' && + mr.status === "in_progress" && mr.pr_url && staleMs(mr.updated_at, ORPHANED_PR_REVIEW_TIMEOUT_MS) ) { const workingAgent = hasWorkingAgentHooked(sql, mr.bead_id); if (!workingAgent) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: mr.bead_id, from: mr.status, - to: 'failed', - reason: 'PR review orphaned', - actor: 'system', + to: "failed", + reason: "PR review orphaned", + actor: "system", }); } } @@ -848,7 +868,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.status} = 'open' AND b.${beads.columns.rig_id} IS NOT NULL `, - [] + [], ), ]); @@ -872,7 +892,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.rig_id} = ? AND rm.${review_metadata.columns.pr_url} IS NULL `, - [rig_id] + [rig_id], ), ]); if ((inProgressCount[0]?.cnt ?? 0) > 0) continue; @@ -893,7 +913,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.rig_id} = ? LIMIT 1 `, - [rig_id] + [rig_id], ), ]); @@ -913,7 +933,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { ORDER BY ${beads.columns.created_at} ASC LIMIT 1 `, - [rig_id] + [rig_id], ), ]); @@ -923,16 +943,16 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // agent_id — applyAction will create the refinery via getOrCreateAgent. if (refinery.length === 0) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: oldestMr[0].bead_id, - from: 'open', - to: 'in_progress', - reason: 'popped for review (creating refinery)', - actor: 'system', + from: "open", + to: "in_progress", + reason: "popped for review (creating refinery)", + actor: "system", }); actions.push({ - type: 'dispatch_agent', - agent_id: '', + type: "dispatch_agent", + agent_id: "", bead_id: oldestMr[0].bead_id, rig_id, }); @@ -940,23 +960,23 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { } const ref = refinery[0]; - if (ref.status !== 'idle' || ref.current_hook_bead_id) continue; + if (ref.status !== "idle" || ref.current_hook_bead_id) continue; actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: oldestMr[0].bead_id, - from: 'open', - to: 'in_progress', - reason: 'popped for review', - actor: 'system', + from: "open", + to: "in_progress", + reason: "popped for review", + actor: "system", }); actions.push({ - type: 'hook_agent', + type: "hook_agent", agent_id: ref.bead_id, bead_id: oldestMr[0].bead_id, }); actions.push({ - type: 'dispatch_agent', + type: "dispatch_agent", agent_id: ref.bead_id, bead_id: oldestMr[0].bead_id, rig_id, @@ -979,7 +999,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND ${agent_metadata.status} = 'idle' AND ${agent_metadata.current_hook_bead_id} IS NOT NULL `, - [] + [], ), ]); @@ -992,17 +1012,17 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Circuit-breaker: fail the MR bead after too many attempts (#1342) if (ref.dispatch_attempts >= MAX_DISPATCH_ATTEMPTS) { actions.push({ - type: 'transition_bead', + type: "transition_bead", bead_id: ref.current_hook_bead_id, from: null, - to: 'failed', - reason: 'refinery max dispatch attempts exceeded', - actor: 'system', + to: "failed", + reason: "refinery max dispatch attempts exceeded", + actor: "system", }); actions.push({ - type: 'unhook_agent', + type: "unhook_agent", agent_id: ref.bead_id, - reason: 'max dispatch attempts', + reason: "max dispatch attempts", }); continue; } @@ -1022,21 +1042,21 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [ref.current_hook_bead_id] + [ref.current_hook_bead_id], ), ]); if (mrRows.length === 0) continue; const mr = mrRows[0]; - if (mr.type !== 'merge_request' || mr.status !== 'in_progress') continue; + if (mr.type !== "merge_request" || mr.status !== "in_progress") continue; // Container status is checked at apply time (async). In shadow mode, // we just note that a dispatch is needed. actions.push({ - type: 'dispatch_agent', + type: "dispatch_agent", agent_id: ref.bead_id, bead_id: ref.current_hook_bead_id, - rig_id: mr.rig_id ?? ref.rig_id ?? '', + rig_id: mr.rig_id ?? ref.rig_id ?? "", }); } @@ -1066,7 +1086,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'convoy' AND b.${beads.columns.status} = 'open' `, - [] + [], ), ]); @@ -1088,7 +1108,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND tracked.${beads.columns.type} = 'issue' `, - [convoy.bead_id] + [convoy.bead_id], ), ]); @@ -1098,7 +1118,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { // Update progress if stale if (closed_count !== convoy.closed_beads) { actions.push({ - type: 'update_convoy_progress', + type: "update_convoy_progress", convoy_id: convoy.bead_id, closed_beads: closed_count, }); @@ -1124,7 +1144,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND mr.${beads.columns.type} = 'merge_request' AND mr.${beads.columns.status} IN ('open', 'in_progress') `, - [convoy.bead_id] + [convoy.bead_id], ), ]); @@ -1139,10 +1159,10 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { /* ignore */ } - if (convoy.merge_mode === 'review-then-land' && convoy.feature_branch) { + if (convoy.merge_mode === "review-then-land" && convoy.feature_branch) { if (!parsedMeta.ready_to_land) { actions.push({ - type: 'set_convoy_ready_to_land', + type: "set_convoy_ready_to_land", convoy_id: convoy.bead_id, }); } @@ -1163,15 +1183,17 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND mr.${beads.columns.type} = 'merge_request' `, - [convoy.bead_id] + [convoy.bead_id], ), ]); // If a landing MR was already merged (closed), close the convoy - const hasMergedLanding = landingMrs.some(mr => mr.status === 'closed'); + const hasMergedLanding = landingMrs.some( + (mr) => mr.status === "closed", + ); if (hasMergedLanding) { actions.push({ - type: 'close_convoy', + type: "close_convoy", convoy_id: convoy.bead_id, }); continue; @@ -1179,7 +1201,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { // If a landing MR is active (open or in_progress), wait for it const hasActiveLanding = landingMrs.some( - mr => mr.status === 'open' || mr.status === 'in_progress' + (mr) => mr.status === "open" || mr.status === "in_progress", ); if (hasActiveLanding) continue; @@ -1201,18 +1223,18 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND tracked.${beads.columns.rig_id} IS NOT NULL LIMIT 1 `, - [convoy.bead_id] + [convoy.bead_id], ), ]); if (rigRows.length > 0) { const rig = getRig(sql, rigRows[0].rig_id); actions.push({ - type: 'create_landing_mr', + type: "create_landing_mr", convoy_id: convoy.bead_id, rig_id: rigRows[0].rig_id, feature_branch: convoy.feature_branch, - target_branch: rig?.default_branch ?? 'main', + target_branch: rig?.default_branch ?? "main", }); } } @@ -1220,7 +1242,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { } else { // review-and-merge or no feature branch — auto-close actions.push({ - type: 'close_convoy', + type: "close_convoy", convoy_id: convoy.bead_id, }); } @@ -1253,7 +1275,7 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { LEFT JOIN ${beads} b ON b.${beads.columns.bead_id} = ${agent_metadata.bead_id} WHERE ${agent_metadata.status} IN ('working', 'stalled') `, - [] + [], ), ]); @@ -1269,37 +1291,37 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { if (elapsed > GUPP_FORCE_STOP_MS) { actions.push({ - type: 'transition_agent', + type: "transition_agent", agent_id: agent.bead_id, from: agent.status, - to: 'stalled', - reason: 'GUPP force stop — no SDK activity for 2h', + to: "stalled", + reason: "GUPP force stop — no SDK activity for 2h", }); actions.push({ - type: 'stop_agent', + type: "stop_agent", agent_id: agent.bead_id, - reason: 'exceeded 2h GUPP limit', + reason: "exceeded 2h GUPP limit", }); actions.push({ - type: 'create_triage_request', + type: "create_triage_request", agent_id: agent.bead_id, - triage_type: 'stuck_agent', - reason: 'GUPP force stop', + triage_type: "stuck_agent", + reason: "GUPP force stop", }); } else if (elapsed > GUPP_ESCALATE_MS) { - if (!hasRecentNudge(sql, agent.bead_id, 'escalate')) { + if (!hasRecentNudge(sql, agent.bead_id, "escalate")) { actions.push({ - type: 'send_nudge', + type: "send_nudge", agent_id: agent.bead_id, message: - 'You have been working for over 1 hour without completing your task. Please wrap up or report if you are stuck.', - tier: 'escalate', + "You have been working for over 1 hour without completing your task. Please wrap up or report if you are stuck.", + tier: "escalate", }); actions.push({ - type: 'create_triage_request', + type: "create_triage_request", agent_id: agent.bead_id, - triage_type: 'stuck_agent', - reason: 'GUPP escalation', + triage_type: "stuck_agent", + reason: "GUPP escalation", }); } } else if (elapsed > 15 * 60_000) { @@ -1307,18 +1329,18 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { // Skip if agent is mid-tool-call — long-running tools like git clone are normal. let tools: string[] = []; try { - tools = JSON.parse(agent.active_tools ?? '[]') as string[]; + tools = JSON.parse(agent.active_tools ?? "[]") as string[]; } catch { /* ignore */ } - if (tools.length === 0 && !hasRecentNudge(sql, agent.bead_id, 'warn')) { + if (tools.length === 0 && !hasRecentNudge(sql, agent.bead_id, "warn")) { actions.push({ - type: 'send_nudge', + type: "send_nudge", agent_id: agent.bead_id, message: - 'You have been idle for 15 minutes with no tool activity. Please check your progress.', - tier: 'warn', + "You have been idle for 15 minutes with no tool activity. Please check your progress.", + tier: "warn", }); } } @@ -1349,16 +1371,16 @@ export function reconcileGC(sql: SqlStorage): Action[] { AND ${agent_metadata.columns.role} IN ('polecat', 'refinery') AND ${agent_metadata.current_hook_bead_id} IS NULL `, - [] + [], ), ]); for (const agent of gcCandidates) { if (staleMs(agent.last_activity_at, AGENT_GC_RETENTION_MS)) { actions.push({ - type: 'delete_agent', + type: "delete_agent", agent_id: agent.bead_id, - reason: 'GC: idle > 24h', + reason: "GC: idle > 24h", }); } } @@ -1369,7 +1391,10 @@ export function reconcileGC(sql: SqlStorage): Action[] { // ── Helpers ───────────────────────────────────────────────────────── /** Check if an MR bead has open rework beads blocking it. */ -function hasUnresolvedReworkBlockers(sql: SqlStorage, mrBeadId: string): boolean { +function hasUnresolvedReworkBlockers( + sql: SqlStorage, + mrBeadId: string, +): boolean { const rows = [ ...query( sql, @@ -1381,7 +1406,7 @@ function hasUnresolvedReworkBlockers(sql: SqlStorage, mrBeadId: string): boolean AND rework.${beads.columns.status} NOT IN ('closed', 'failed') LIMIT 1 `, - [mrBeadId] + [mrBeadId], ), ]; return rows.length > 0; @@ -1397,7 +1422,7 @@ function hasWorkingAgentHooked(sql: SqlStorage, beadId: string): boolean { AND ${agent_metadata.status} IN ('working', 'stalled') LIMIT 1 `, - [beadId] + [beadId], ), ]; return rows.length > 0; @@ -1412,7 +1437,7 @@ function hasAnyAgentHooked(sql: SqlStorage, beadId: string): boolean { WHERE ${agent_metadata.current_hook_bead_id} = ? LIMIT 1 `, - [beadId] + [beadId], ), ]; return rows.length > 0; @@ -1432,13 +1457,17 @@ function getIdleAgentHookedTo(sql: SqlStorage, beadId: string): string | null { AND ${agent_metadata.status} = 'idle' LIMIT 1 `, - [beadId] + [beadId], ), ]); return rows.length > 0 ? rows[0].bead_id : null; } -function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean { +function hasRecentNudge( + sql: SqlStorage, + agentId: string, + tier: string, +): boolean { // Check if a nudge with this exact tier source was created in the last 60 min. // The source is set to `reconciler:${tier}` by applyAction('send_nudge'). const cutoff = new Date(Date.now() - 60 * 60_000).toISOString(); @@ -1452,7 +1481,7 @@ function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean AND ${agent_nudges.created_at} > ? LIMIT 1 `, - [agentId, `reconciler:${tier}`, cutoff] + [agentId, `reconciler:${tier}`, cutoff], ), ]; return rows.length > 0; @@ -1489,7 +1518,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { WHERE ${agent_metadata.status} = 'working' AND ${agent_metadata.current_hook_bead_id} IS NULL `, - [] + [], ), ]); for (const a of unhookedWorkers) { @@ -1512,7 +1541,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { WHERE ${beads.type} = 'convoy' AND ${beads.status} = 'in_progress' `, - [] + [], ), ]); for (const c of inProgressConvoys) { @@ -1538,7 +1567,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { GROUP BY ${beads.rig_id} HAVING count(*) > 1 `, - [] + [], ), ]); for (const r of duplicateMrPerRig) { @@ -1562,7 +1591,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { GROUP BY ${agent_metadata.current_hook_bead_id} HAVING count(*) > 1 `, - [] + [], ), ]); for (const m of multiHooked) { @@ -1594,7 +1623,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { AND mr.${beads.columns.status} IN ('open', 'in_progress') ) `, - [] + [], ), ]); for (const b of orphanedInReview) { From 26ef108a004306d826e3ad5e8e0c568375a35a0e Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 09:48:58 -0500 Subject: [PATCH 4/8] fix(gastown): set refinery to idle after gt_done so next review can start immediately The refinery's gt_done path unhooks the agent but doesn't set it to idle. The refinery stays 'working' with no hook until agentCompleted fires (when the container process exits, which can take 10-30s after gt_done). During that time processReviewQueue sees the refinery as non-idle and won't pop the next MR bead. Set the refinery to idle immediately after unhooking in agentDone. The container process continues running but the DO knows the refinery is available for new reviews. --- .../src/dos/town/review-queue.ts | 324 +++++++++++------- 1 file changed, 192 insertions(+), 132 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 5b746cb6c..a516cd1fe 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -6,13 +6,17 @@ * - Molecules are parent beads with type='molecule' + child step beads */ -import { z } from 'zod'; -import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads.table'; -import { review_metadata } from '../../db/tables/review-metadata.table'; -import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; -import { agent_metadata } from '../../db/tables/agent-metadata.table'; -import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; -import { query } from '../../util/query.util'; +import { z } from "zod"; +import { + beads, + BeadRecord, + MergeRequestBeadRecord, +} from "../../db/tables/beads.table"; +import { review_metadata } from "../../db/tables/review-metadata.table"; +import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; +import { agent_metadata } from "../../db/tables/agent-metadata.table"; +import { convoy_metadata } from "../../db/tables/convoy-metadata.table"; +import { query } from "../../util/query.util"; import { logBeadEvent, getBead, @@ -23,10 +27,15 @@ import { getConvoyForBead, getConvoyFeatureBranch, getConvoyMergeMode, -} from './beads'; -import { getAgent, unhookBead } from './agents'; -import { getRig } from './rigs'; -import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; +} from "./beads"; +import { getAgent, unhookBead, updateAgentStatus } from "./agents"; +import { getRig } from "./rigs"; +import type { + ReviewQueueInput, + ReviewQueueEntry, + AgentDoneInput, + Molecule, +} from "../../types"; // Review entries stuck in 'running' past this timeout are reset to 'pending'. // Only applies when no agent (working or idle) is hooked to the MR bead. @@ -65,29 +74,34 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { // The polecat that submitted the review — stored in metadata (not assignee, // which is set to the refinery when it claims the MR bead via hookBead). agent_id: - typeof row.metadata?.source_agent_id === 'string' + typeof row.metadata?.source_agent_id === "string" ? row.metadata.source_agent_id - : (row.created_by ?? ''), + : (row.created_by ?? ""), bead_id: - typeof row.metadata?.source_bead_id === 'string' ? row.metadata.source_bead_id : row.bead_id, - rig_id: row.rig_id ?? '', + typeof row.metadata?.source_bead_id === "string" + ? row.metadata.source_bead_id + : row.bead_id, + rig_id: row.rig_id ?? "", branch: row.branch, pr_url: row.pr_url, status: - row.status === 'open' - ? 'pending' - : row.status === 'in_progress' - ? 'running' - : row.status === 'closed' - ? 'merged' - : 'failed', + row.status === "open" + ? "pending" + : row.status === "in_progress" + ? "running" + : row.status === "closed" + ? "merged" + : "failed", summary: row.body, created_at: row.created_at, processed_at: row.updated_at === row.created_at ? null : row.updated_at, }; } -export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { +export function submitToReviewQueue( + sql: SqlStorage, + input: ReviewQueueInput, +): void { const id = generateId(); const timestamp = now(); @@ -107,12 +121,14 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v // - For standalone beads → rig's default branch // We pass defaultBranch from the caller so we don't hardcode 'main'. const convoyId = getConvoyForBead(sql, input.bead_id); - const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; + const convoyFeatureBranch = convoyId + ? getConvoyFeatureBranch(sql, convoyId) + : null; const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; const targetBranch = - convoyMergeMode === 'review-then-land' && convoyFeatureBranch + convoyMergeMode === "review-then-land" && convoyFeatureBranch ? convoyFeatureBranch - : (input.default_branch ?? 'main'); + : (input.default_branch ?? "main"); if (convoyId) { metadata.convoy_id = convoyId; @@ -136,21 +152,21 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v `, [ id, - 'merge_request', - 'open', + "merge_request", + "open", `Review: ${input.branch}`, input.summary ?? null, input.rig_id, null, null, // assignee left null — refinery claims it via hookBead - 'medium', - JSON.stringify(['gt:merge-request']), + "medium", + JSON.stringify(["gt:merge-request"]), JSON.stringify(metadata), input.agent_id, // created_by records who submitted timestamp, timestamp, null, - ] + ], ); // Link MR bead → source bead via bead_dependencies so the DAG is queryable @@ -163,7 +179,7 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, 'tracks') `, - [id, input.bead_id] + [id, input.bead_id], ); // Create the review_metadata satellite @@ -176,13 +192,13 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] + [id, input.branch, targetBranch, null, input.pr_url ?? null, 0], ); logBeadEvent(sql, { beadId: input.bead_id, agentId: input.agent_id, - eventType: 'review_submitted', + eventType: "review_submitted", newValue: input.branch, metadata: { branch: input.branch, target_branch: targetBranch }, }); @@ -212,7 +228,7 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ORDER BY ${beads.created_at} ASC LIMIT 1 `, - [] + [], ), ]; @@ -229,28 +245,28 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), entry.id] + [now(), entry.id], ); - return { ...entry, status: 'running', processed_at: now() }; + return { ...entry, status: "running", processed_at: now() }; } export function completeReview( sql: SqlStorage, entryId: string, - status: 'merged' | 'failed' + status: "merged" | "failed", ): void { // Guard: don't overwrite terminal states (closed MR bead that was // already merged should never be set to 'failed' by a stale call) const current = getBead(sql, entryId); - if (current && (current.status === 'closed' || current.status === 'failed')) { + if (current && (current.status === "closed" || current.status === "failed")) { console.warn( - `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping` + `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping`, ); return; } - const beadStatus = status === 'merged' ? 'closed' : 'failed'; + const beadStatus = status === "merged" ? "closed" : "failed"; const timestamp = now(); query( sql, @@ -261,7 +277,12 @@ export function completeReview( ${beads.columns.closed_at} = ? WHERE ${beads.bead_id} = ? `, - [beadStatus, timestamp, beadStatus === 'closed' ? timestamp : null, entryId] + [ + beadStatus, + timestamp, + beadStatus === "closed" ? timestamp : null, + entryId, + ], ); } @@ -272,18 +293,20 @@ export function completeReviewWithResult( sql: SqlStorage, input: { entry_id: string; - status: 'merged' | 'failed' | 'conflict'; + status: "merged" | "failed" | "conflict"; message?: string; commit_sha?: string; - } + }, ): void { // On conflict, mark the review entry as failed and create an escalation bead - const resolvedStatus = input.status === 'conflict' ? 'failed' : input.status; + const resolvedStatus = input.status === "conflict" ? "failed" : input.status; completeReview(sql, input.entry_id, resolvedStatus); // Find the review entry to get agent IDs const entryRows = [ - ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [input.entry_id]), + ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [ + input.entry_id, + ]), ]; if (entryRows.length === 0) return; const parsed = MergeRequestBeadRecord.parse(entryRows[0]); @@ -292,7 +315,7 @@ export function completeReviewWithResult( logBeadEvent(sql, { beadId: entry.bead_id, agentId: entry.agent_id, - eventType: 'review_completed', + eventType: "review_completed", newValue: input.status, metadata: { message: input.message, @@ -300,12 +323,12 @@ export function completeReviewWithResult( }, }); - if (input.status === 'merged') { + if (input.status === "merged") { const mergeTimestamp = now(); console.log( `[review-queue] completeReviewWithResult MERGED: entry_id=${input.entry_id} ` + `entry.bead_id (source)=${entry.bead_id} entry.id (MR)=${entry.id} — ` + - `calling closeBead on source` + `calling closeBead on source`, ); closeBead(sql, entry.bead_id, entry.agent_id); @@ -331,7 +354,7 @@ export function completeReviewWithResult( AND dep.${bead_dependencies.columns.dependency_type} = 'tracks' ) `, - [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id] + [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id], ); // closeBead → updateBeadStatus short-circuits when completeReview already @@ -342,7 +365,7 @@ export function completeReviewWithResult( // If this was a convoy landing MR, also set landed_at on the convoy metadata const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead?.type === 'convoy') { + if (sourceBead?.type === "convoy") { query( sql, /* sql */ ` @@ -350,16 +373,16 @@ export function completeReviewWithResult( SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [now(), entry.bead_id] + [now(), entry.bead_id], ); } - } else if (input.status === 'conflict') { + } else if (input.status === "conflict") { // Create an escalation bead so the conflict is visible and actionable createBead(sql, { - type: 'escalation', + type: "escalation", title: `Merge conflict: ${input.message ?? entry.branch}`, body: input.message, - priority: 'high', + priority: "high", metadata: { source_bead_id: entry.bead_id, source_agent_id: entry.agent_id, @@ -372,10 +395,10 @@ export function completeReviewWithResult( const conflictSourceBead = getBead(sql, entry.bead_id); if ( conflictSourceBead && - conflictSourceBead.status !== 'closed' && - conflictSourceBead.status !== 'failed' + conflictSourceBead.status !== "closed" && + conflictSourceBead.status !== "failed" ) { - updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); + updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); query( sql, /* sql */ ` @@ -383,10 +406,10 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id] + [entry.bead_id], ); } - } else if (input.status === 'failed') { + } else if (input.status === "failed") { // Review failed (rework requested): return source bead to open so // the normal scheduling path (feedStrandedConvoys → hookBead → // schedulePendingWork → dispatch) handles rework. Clear the stale @@ -394,8 +417,12 @@ export function completeReviewWithResult( // This avoids the fire-and-forget rework dispatch race in TownDO // where the dispatch fails and rehookOrphanedBeads churn. const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead && sourceBead.status !== 'closed' && sourceBead.status !== 'failed') { - updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); + if ( + sourceBead && + sourceBead.status !== "closed" && + sourceBead.status !== "failed" + ) { + updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); query( sql, /* sql */ ` @@ -403,7 +430,7 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id] + [entry.bead_id], ); } } @@ -418,7 +445,7 @@ export function completeReviewWithResult( /** Get review_metadata for an MR bead. */ export function getReviewMetadata( sql: SqlStorage, - mrBeadId: string + mrBeadId: string, ): { branch: string; target_branch: string; pr_url: string | null } | null { const rows = z .object({ @@ -437,17 +464,23 @@ export function getReviewMetadata( FROM ${review_metadata} WHERE ${review_metadata.bead_id} = ? `, - [mrBeadId] + [mrBeadId], ), ]); return rows[0] ?? null; } -export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): boolean { +export function setReviewPrUrl( + sql: SqlStorage, + entryId: string, + prUrl: string, +): boolean { // Reject non-HTTPS URLs to prevent storing garbage from LLM output. // Invalid URLs would cause pollPendingPRs to poll indefinitely. - if (!prUrl.startsWith('https://')) { - console.warn(`[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`); + if (!prUrl.startsWith("https://")) { + console.warn( + `[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`, + ); return false; } query( @@ -457,7 +490,7 @@ export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): SET ${review_metadata.columns.pr_url} = ? WHERE ${review_metadata.bead_id} = ? `, - [prUrl, entryId] + [prUrl, entryId], ); // Also write to bead metadata so the PR URL is visible in the standard bead list @@ -468,7 +501,7 @@ export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.pr_url', ?) WHERE ${beads.bead_id} = ? `, - [prUrl, entryId] + [prUrl, entryId], ); return true; } @@ -486,13 +519,17 @@ export function markReviewInReview(sql: SqlStorage, entryId: string): void { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [new Date().toISOString(), entryId] + [new Date().toISOString(), entryId], ); } // ── Agent Done ────────────────────────────────────────────────────── -export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { +export function agentDone( + sql: SqlStorage, + agentId: string, + input: AgentDoneInput, +): void { const agent = getAgent(sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) { @@ -504,7 +541,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // but the hook was cleared by zombie detection. We MUST still complete // the review — otherwise the source bead stays open forever. Find the // most recent non-closed MR bead assigned to this agent and complete it. - if (agent.role === 'refinery') { + if (agent.role === "refinery") { const recentMrRows = [ ...query( sql, @@ -517,13 +554,15 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu ORDER BY ${beads.updated_at} DESC LIMIT 1 `, - [agentId] + [agentId], ), ]; if (recentMrRows.length > 0) { - const mrBeadId = z.object({ bead_id: z.string() }).parse(recentMrRows[0]).bead_id; + const mrBeadId = z + .object({ bead_id: z.string() }) + .parse(recentMrRows[0]).bead_id; console.log( - `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}` + `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}`, ); if (input.pr_url) { const stored = setReviewPrUrl(sql, mrBeadId, input.pr_url); @@ -532,15 +571,17 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'failed', + status: "failed", message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); } } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'merged', - message: input.summary ?? 'Merged by refinery agent (recovered from unhook)', + status: "merged", + message: + input.summary ?? + "Merged by refinery agent (recovered from unhook)", }); } return; @@ -548,7 +589,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu } console.warn( - `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring` + `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring`, ); return; } @@ -558,7 +599,10 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // beads (created_by = 'patrol'). User-created beads that happen to carry // the gt:triage label go through normal review flow. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (hookedBead?.labels.includes('gt:triage') && hookedBead.created_by === 'patrol') { + if ( + hookedBead?.labels.includes("gt:triage") && + hookedBead.created_by === "patrol" + ) { closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; @@ -568,16 +612,16 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // to an existing branch (the one the refinery already reviewed). Closing // the rework bead unblocks the MR bead, and the reconciler re-dispatches // the refinery to re-review. - if (hookedBead?.labels.includes('gt:rework')) { + if (hookedBead?.labels.includes("gt:rework")) { console.log( - `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)` + `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)`, ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; } - if (agent.role === 'refinery') { + if (agent.role === "refinery") { // The refinery handles merging (direct strategy) or PR creation (pr strategy) // itself. When it calls gt_done: // - With pr_url: refinery created a PR → store URL, mark as in_review, poll it @@ -593,34 +637,39 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: 'pr_created', + eventType: "pr_created", newValue: input.pr_url, - metadata: { pr_url: input.pr_url, created_by: 'refinery' }, + metadata: { pr_url: input.pr_url, created_by: "refinery" }, }); } else { // Invalid URL — fail the review so it doesn't poll forever completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'failed', + status: "failed", message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: 'pr_creation_failed', - metadata: { pr_url: input.pr_url, reason: 'invalid_url' }, + eventType: "pr_creation_failed", + metadata: { pr_url: input.pr_url, reason: "invalid_url" }, }); } } else { // Direct strategy: refinery already merged and pushed completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'merged', - message: input.summary ?? 'Merged by refinery agent', + status: "merged", + message: input.summary ?? "Merged by refinery agent", }); } unhookBead(sql, agentId); + // Set refinery to idle immediately — the review is done and the + // refinery is available for new work. Without this, processReviewQueue + // sees the refinery as 'working' and won't pop the next MR bead until + // agentCompleted fires (when the container process eventually exits). + updateAgentStatus(sql, agentId, "idle"); return; } @@ -628,13 +677,13 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu if (!agent.rig_id) { console.warn( - `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue` + `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue`, ); } // Resolve the rig's default branch so submitToReviewQueue can use it // instead of hardcoding 'main' for standalone/review-and-merge beads. - const rigId = agent.rig_id ?? ''; + const rigId = agent.rig_id ?? ""; const rig = rigId ? getRig(sql, rigId) : null; submitToReviewQueue(sql, { @@ -653,7 +702,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // worked on it. It will be closed (or returned to in_progress) by the // refinery after review. unhookBead(sql, agentId); - updateBeadStatus(sql, sourceBead, 'in_review', agentId); + updateBeadStatus(sql, sourceBead, "in_review", agentId); } /** @@ -676,14 +725,14 @@ export type AgentCompletedResult = { export function agentCompleted( sql: SqlStorage, agentId: string, - input: { status: 'completed' | 'failed'; reason?: string } + input: { status: "completed" | "failed"; reason?: string }, ): AgentCompletedResult { const result: AgentCompletedResult = { reworkSourceBeadId: null }; const agent = getAgent(sql, agentId); if (!agent) return result; if (agent.current_hook_bead_id) { - if (agent.role === 'refinery') { + if (agent.role === "refinery") { // NEVER fail or unhook a refinery from agentCompleted. // agentCompleted races with gt_done: the process exits, the // container sends /completed, but gt_done's HTTP request may @@ -704,16 +753,16 @@ export function agentCompleted( // before calling gt_done. Don't close the bead — just unhook. The reconciler's // Rule 3 will reset it to open after the staleness timeout. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (input.status === 'failed') { - updateBeadStatus(sql, agent.current_hook_bead_id, 'failed', agentId); - } else if (hookedBead && hookedBead.status === 'in_progress') { + if (input.status === "failed") { + updateBeadStatus(sql, agent.current_hook_bead_id, "failed", agentId); + } else if (hookedBead && hookedBead.status === "in_progress") { // Agent exited 'completed' but bead is still in_progress — gt_done was never called. // Don't close the bead. Rule 3 will handle rework. console.log( `[review-queue] agentCompleted: polecat ${agentId} exited without gt_done — ` + - `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)` + `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)`, ); - } else if (hookedBead && hookedBead.status === 'open') { + } else if (hookedBead && hookedBead.status === "open") { // Bead is open (wasn't dispatched yet or was already reset). No-op. } else { // Bead is in_review, closed, or failed — gt_done already ran. No-op on bead. @@ -738,7 +787,7 @@ export function agentCompleted( END WHERE ${agent_metadata.bead_id} = ? `, - [agentId] + [agentId], ); return result; @@ -750,7 +799,11 @@ export function agentCompleted( * Create a molecule: a parent bead with type='molecule', child step beads * linked via parent_bead_id, and step ordering via bead_dependencies. */ -export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown): Molecule { +export function createMolecule( + sql: SqlStorage, + beadId: string, + formula: unknown, +): Molecule { const id = generateId(); const timestamp = now(); const formulaArr = Array.isArray(formula) ? formula : []; @@ -770,21 +823,21 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown `, [ id, - 'molecule', - 'open', + "molecule", + "open", `Molecule for bead ${beadId}`, null, null, null, null, - 'medium', - JSON.stringify(['gt:molecule']), + "medium", + JSON.stringify(["gt:molecule"]), JSON.stringify({ source_bead_id: beadId, formula }), null, timestamp, timestamp, null, - ] + ], ); // Create child step beads and dependency chain @@ -807,21 +860,22 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown `, [ stepId, - 'issue', - 'open', - z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, - typeof step === 'string' ? step : JSON.stringify(step), + "issue", + "open", + z.object({ title: z.string() }).safeParse(step).data?.title ?? + `Step ${i + 1}`, + typeof step === "string" ? step : JSON.stringify(step), null, id, null, - 'medium', + "medium", JSON.stringify([`gt:molecule-step`, `step:${i}`]), JSON.stringify({ step_index: i, step_data: step }), null, timestamp, timestamp, null, - ] + ], ); // Chain dependencies: each step blocks on the previous @@ -835,7 +889,7 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [stepId, prevStepId, 'blocks'] + [stepId, prevStepId, "blocks"], ); } prevStepId = stepId; @@ -849,32 +903,35 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown SET ${beads.columns.metadata} = json_set(${beads.metadata}, '$.molecule_bead_id', ?) WHERE ${beads.bead_id} = ? `, - [id, beadId] + [id, beadId], ); const mol = getMolecule(sql, id); - if (!mol) throw new Error('Failed to create molecule'); + if (!mol) throw new Error("Failed to create molecule"); return mol; } /** * Get a molecule by its bead_id. Derives current_step and status from children. */ -export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | null { +export function getMolecule( + sql: SqlStorage, + moleculeId: string, +): Molecule | null { const bead = getBead(sql, moleculeId); - if (!bead || bead.type !== 'molecule') return null; + if (!bead || bead.type !== "molecule") return null; const steps = getStepBeads(sql, moleculeId); - const closedCount = steps.filter(s => s.status === 'closed').length; - const failedCount = steps.filter(s => s.status === 'failed').length; + const closedCount = steps.filter((s) => s.status === "closed").length; + const failedCount = steps.filter((s) => s.status === "failed").length; const currentStep = closedCount; const status = failedCount > 0 - ? 'failed' + ? "failed" : closedCount >= steps.length && steps.length > 0 - ? 'completed' - : 'active'; + ? "completed" + : "active"; const formula: unknown = bead.metadata?.formula ?? []; @@ -898,29 +955,32 @@ function getStepBeads(sql: SqlStorage, moleculeId: string): BeadRecord[] { WHERE ${beads.parent_bead_id} = ? ORDER BY ${beads.created_at} ASC `, - [moleculeId] + [moleculeId], ), ]; return BeadRecord.array().parse(rows); } -export function getMoleculeForBead(sql: SqlStorage, beadId: string): Molecule | null { +export function getMoleculeForBead( + sql: SqlStorage, + beadId: string, +): Molecule | null { const bead = getBead(sql, beadId); if (!bead) return null; const moleculeId: unknown = bead.metadata?.molecule_bead_id; - if (typeof moleculeId !== 'string') return null; + if (typeof moleculeId !== "string") return null; return getMolecule(sql, moleculeId); } export function getMoleculeCurrentStep( sql: SqlStorage, - agentId: string + agentId: string, ): { molecule: Molecule; step: unknown } | null { const agent = getAgent(sql, agentId); if (!agent?.current_hook_bead_id) return null; const mol = getMoleculeForBead(sql, agent.current_hook_bead_id); - if (!mol || mol.status !== 'active') return null; + if (!mol || mol.status !== "active") return null; const formula = mol.formula; if (!Array.isArray(formula)) return null; @@ -932,7 +992,7 @@ export function getMoleculeCurrentStep( export function advanceMoleculeStep( sql: SqlStorage, agentId: string, - _summary: string + _summary: string, ): Molecule | null { const current = getMoleculeCurrentStep(sql, agentId); if (!current) return null; @@ -953,7 +1013,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, currentStepBead.bead_id] + [timestamp, timestamp, currentStepBead.bead_id], ); } @@ -974,7 +1034,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, molecule.id] + [timestamp, timestamp, molecule.id], ); } From 08e56f116510e9ccf03e2ec20c7852682d7dbb56 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 10:30:11 -0500 Subject: [PATCH 5/8] fix(gastown): set working agents with no hook to idle in reconcileAgents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working agents with fresh heartbeats but no hook are running in the container doing nothing — gt_done already ran and unhooked them, or the hook was cleared by another path. Without this, the refinery stays 'working' indefinitely (heartbeats keep it alive), blocking processReviewQueue from dispatching it for the next review. Also skip the mayor in the working-agent check (mayors are always working with no hook — that's normal). This eliminates the invariant 7 false positive from #1364. --- cloudflare-gastown/src/dos/town/reconciler.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cloudflare-gastown/src/dos/town/reconciler.ts b/cloudflare-gastown/src/dos/town/reconciler.ts index 40df14393..38ec480da 100644 --- a/cloudflare-gastown/src/dos/town/reconciler.ts +++ b/cloudflare-gastown/src/dos/town/reconciler.ts @@ -369,6 +369,9 @@ export function reconcileAgents(sql: SqlStorage): Action[] { ]); for (const agent of workingAgents) { + // Mayors are always working with no hook — skip them + if (agent.role === "mayor") continue; + if (!agent.last_activity_at) { // No heartbeat ever received — container may have failed to start actions.push({ @@ -386,6 +389,18 @@ export function reconcileAgents(sql: SqlStorage): Action[] { to: "idle", reason: "heartbeat lost (3 missed cycles)", }); + } else if (!agent.current_hook_bead_id) { + // Agent is working with fresh heartbeat but no hook — it's running + // in the container but has no bead to work on (gt_done already ran, + // or the hook was cleared by another code path). Set to idle so + // processReviewQueue / schedulePendingWork can use it. + actions.push({ + type: "transition_agent", + agent_id: agent.bead_id, + from: "working", + to: "idle", + reason: "working agent has no hook (gt_done already completed)", + }); } } From de3ada6eba8ad61b22f70e682365bcc29dfb1c83 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 19:15:14 -0500 Subject: [PATCH 6/8] style: run oxfmt formatter --- cloudflare-gastown/src/dos/Town.do.ts | 1438 +++++++---------- cloudflare-gastown/src/dos/town/reconciler.ts | 461 +++--- .../src/dos/town/review-queue.ts | 321 ++-- 3 files changed, 921 insertions(+), 1299 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 5a2dc36c9..92c4c0363 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -13,29 +13,26 @@ * AgentDOs to stay within the 10GB DO SQLite limit. */ -import { DurableObject } from "cloudflare:workers"; -import * as Sentry from "@sentry/cloudflare"; -import { z } from "zod"; +import { DurableObject } from 'cloudflare:workers'; +import * as Sentry from '@sentry/cloudflare'; +import { z } from 'zod'; // Sub-modules (plain functions, not classes — per coding style) -import * as beadOps from "./town/beads"; -import * as agents from "./town/agents"; -import * as mail from "./town/mail"; -import * as reviewQueue from "./town/review-queue"; -import * as config from "./town/config"; -import * as rigs from "./town/rigs"; -import * as dispatch from "./town/container-dispatch"; -import * as patrol from "./town/patrol"; -import * as scheduling from "./town/scheduling"; -import * as events from "./town/events"; -import * as reconciler from "./town/reconciler"; -import { applyAction } from "./town/actions"; -import type { ApplyActionContext } from "./town/actions"; -import { buildRefinerySystemPrompt } from "../prompts/refinery-system.prompt"; -import { - GitHubPRStatusSchema, - GitLabMRStatusSchema, -} from "../util/platform-pr.util"; +import * as beadOps from './town/beads'; +import * as agents from './town/agents'; +import * as mail from './town/mail'; +import * as reviewQueue from './town/review-queue'; +import * as config from './town/config'; +import * as rigs from './town/rigs'; +import * as dispatch from './town/container-dispatch'; +import * as patrol from './town/patrol'; +import * as scheduling from './town/scheduling'; +import * as events from './town/events'; +import * as reconciler from './town/reconciler'; +import { applyAction } from './town/actions'; +import type { ApplyActionContext } from './town/actions'; +import { buildRefinerySystemPrompt } from '../prompts/refinery-system.prompt'; +import { GitHubPRStatusSchema, GitLabMRStatusSchema } from '../util/platform-pr.util'; // Table imports for beads-centric operations import { @@ -43,27 +40,24 @@ import { BeadRecord, EscalationBeadRecord, ConvoyBeadRecord, -} from "../db/tables/beads.table"; -import { - agent_metadata, - AgentMetadataRecord, -} from "../db/tables/agent-metadata.table"; -import { review_metadata } from "../db/tables/review-metadata.table"; -import { escalation_metadata } from "../db/tables/escalation-metadata.table"; -import { convoy_metadata } from "../db/tables/convoy-metadata.table"; -import { bead_dependencies } from "../db/tables/bead-dependencies.table"; +} from '../db/tables/beads.table'; +import { agent_metadata, AgentMetadataRecord } from '../db/tables/agent-metadata.table'; +import { review_metadata } from '../db/tables/review-metadata.table'; +import { escalation_metadata } from '../db/tables/escalation-metadata.table'; +import { convoy_metadata } from '../db/tables/convoy-metadata.table'; +import { bead_dependencies } from '../db/tables/bead-dependencies.table'; import { agent_nudges, AgentNudgeRecord, createTableAgentNudges, getIndexesAgentNudges, -} from "../db/tables/agent-nudges.table"; -import { query } from "../util/query.util"; -import { getAgentDOStub } from "./Agent.do"; -import { getTownContainerStub } from "./TownContainer.do"; +} from '../db/tables/agent-nudges.table'; +import { query } from '../util/query.util'; +import { getAgentDOStub } from './Agent.do'; +import { getTownContainerStub } from './TownContainer.do'; -import { writeEvent, type GastownEventData } from "../util/analytics.util"; -import { BeadPriority } from "../types"; +import { writeEvent, type GastownEventData } from '../util/analytics.util'; +import { BeadPriority } from '../types'; import type { TownConfig, TownConfigUpdate, @@ -87,41 +81,37 @@ import type { MergeStrategy, ConvoyMergeMode, UiAction, -} from "../types"; +} from '../types'; -const TOWN_LOG = "[Town.do]"; +const TOWN_LOG = '[Town.do]'; /** Format a bead_events row into a human-readable message for the status feed. */ function formatEventMessage(row: Record): string { - const s = (v: unknown) => (v == null ? "" : `${v as string}`); + const s = (v: unknown) => (v == null ? '' : `${v as string}`); const eventType = s(row.event_type); const beadTitle = row.bead_title ? s(row.bead_title) : null; const newValue = row.new_value ? s(row.new_value) : null; const agentId = row.agent_id ? s(row.agent_id).slice(0, 8) : null; const beadId = row.bead_id ? s(row.bead_id).slice(0, 8) : null; - const target = beadTitle - ? `"${beadTitle}"` - : beadId - ? `bead ${beadId}…` - : "unknown"; - const actor = agentId ? `agent ${agentId}…` : "system"; + const target = beadTitle ? `"${beadTitle}"` : beadId ? `bead ${beadId}…` : 'unknown'; + const actor = agentId ? `agent ${agentId}…` : 'system'; switch (eventType) { - case "status_changed": - return `${target} → ${newValue ?? "?"} (by ${actor})`; - case "assigned": + case 'status_changed': + return `${target} → ${newValue ?? '?'} (by ${actor})`; + case 'assigned': return `${target} assigned to ${actor}`; - case "pr_created": + case 'pr_created': return `PR created for ${target}`; - case "pr_merged": + case 'pr_merged': return `PR merged for ${target}`; - case "pr_creation_failed": + case 'pr_creation_failed': return `PR creation failed for ${target}`; - case "escalation_created": + case 'escalation_created': return `Escalation created: ${target}`; - case "agent_status": - return `${actor}: ${newValue ?? "status update"}`; + case 'agent_status': + return `${actor}: ${newValue ?? 'status update'}`; default: return `${eventType}: ${target}`; } @@ -134,7 +124,7 @@ const IDLE_ALARM_INTERVAL_MS = 1 * 60_000; // 1m when idle // Escalation constants const STALE_ESCALATION_THRESHOLD_MS = 4 * 60 * 60 * 1000; const MAX_RE_ESCALATIONS = 3; -const SEVERITY_ORDER = ["low", "medium", "high", "critical"] as const; +const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'] as const; function generateId(): string { return crypto.randomUUID(); @@ -162,7 +152,7 @@ type EscalationEntry = { id: string; source_rig_id: string; source_agent_id: string | null; - severity: "low" | "medium" | "high" | "critical"; + severity: 'low' | 'medium' | 'high' | 'critical'; category: string | null; message: string; acknowledged: number; @@ -174,7 +164,7 @@ type EscalationEntry = { function toEscalation(row: EscalationBeadRecord): EscalationEntry { return { id: row.bead_id, - source_rig_id: row.rig_id ?? "", + source_rig_id: row.rig_id ?? '', source_agent_id: row.created_by, severity: row.severity, category: row.category, @@ -190,7 +180,7 @@ function toEscalation(row: EscalationBeadRecord): EscalationEntry { type ConvoyEntry = { id: string; title: string; - status: "active" | "landed"; + status: 'active' | 'landed'; staged: boolean; total_beads: number; closed_beads: number; @@ -205,7 +195,7 @@ function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { return { id: row.bead_id, title: row.title, - status: row.status === "closed" ? "landed" : "active", + status: row.status === 'closed' ? 'landed' : 'active', staged: row.staged === 1, total_beads: row.total_beads, closed_beads: row.closed_beads, @@ -249,10 +239,10 @@ export class TownDO extends DurableObject { }); } - private emitEvent(data: Omit): void { + private emitEvent(data: Omit): void { writeEvent(this.env, { ...data, - delivery: "internal", + delivery: 'internal', userId: this._ownerUserId, }); } @@ -267,7 +257,7 @@ export class TownDO extends DurableObject { getTownConfig: () => this.getTownConfig(), getRigConfig: (rigId: string) => this.getRigConfig(rigId), resolveKilocodeToken: () => this.resolveKilocodeToken(), - emitEvent: (data) => this.emitEvent(data), + emitEvent: data => this.emitEvent(data), }; } @@ -284,25 +274,23 @@ export class TownDO extends DurableObject { // Build refinery-specific system prompt with branch/target info let systemPromptOverride: string | undefined; - if (agent.role === "refinery" && bead.type === "merge_request") { + if (agent.role === 'refinery' && bead.type === 'merge_request') { const reviewMeta = reviewQueue.getReviewMetadata(this.sql, beadId); const sourceBeadId = - typeof bead.metadata?.source_bead_id === "string" - ? bead.metadata.source_bead_id - : null; + typeof bead.metadata?.source_bead_id === 'string' ? bead.metadata.source_bead_id : null; const townConfig = await this.getTownConfig(); systemPromptOverride = buildRefinerySystemPrompt({ identity: agent.identity, rigId, townId: this.townId, gates: townConfig.refinery?.gates ?? [], - branch: reviewMeta?.branch ?? "unknown", - targetBranch: reviewMeta?.target_branch ?? "main", + branch: reviewMeta?.branch ?? 'unknown', + targetBranch: reviewMeta?.target_branch ?? 'main', polecatAgentId: - typeof bead.metadata?.source_agent_id === "string" + typeof bead.metadata?.source_agent_id === 'string' ? bead.metadata.source_agent_id - : "unknown", - mergeStrategy: townConfig.merge_strategy ?? "direct", + : 'unknown', + mergeStrategy: townConfig.merge_strategy ?? 'direct', }); } @@ -310,29 +298,25 @@ export class TownDO extends DurableObject { systemPromptOverride, }); }, - stopAgent: async (agentId) => { + stopAgent: async agentId => { await dispatch.stopAgentInContainer(this.env, this.townId, agentId); }, - checkPRStatus: async (prUrl) => { + checkPRStatus: async prUrl => { const townConfig = await this.getTownConfig(); return this.checkPRStatus(prUrl, townConfig); }, queueNudge: async (agentId, message, _tier) => { await this.queueNudge(agentId, message, { - mode: "immediate", - priority: "urgent", - source: "reconciler", + mode: 'immediate', + priority: 'urgent', + source: 'reconciler', }); }, insertEvent: (eventType, params) => { - events.insertEvent( - this.sql, - eventType as Parameters[1], - params, - ); + events.insertEvent(this.sql, eventType as Parameters[1], params); }, - emitEvent: (data) => { - if (typeof data.event === "string") { + emitEvent: data => { + if (typeof data.event === 'string') { this.emitEvent(data as Parameters[0]); } }, @@ -348,12 +332,12 @@ export class TownDO extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); if ( - url.pathname.endsWith("/status/ws") && - request.headers.get("Upgrade")?.toLowerCase() === "websocket" + url.pathname.endsWith('/status/ws') && + request.headers.get('Upgrade')?.toLowerCase() === 'websocket' ) { const pair = new WebSocketPair(); const [client, server] = [pair[0], pair[1]]; - this.ctx.acceptWebSocket(server, ["status"]); + this.ctx.acceptWebSocket(server, ['status']); // Send an initial snapshot immediately so the client doesn't // wait for the next alarm tick. @@ -367,14 +351,11 @@ export class TownDO extends DurableObject { return new Response(null, { status: 101, webSocket: client }); } - return new Response("Not found", { status: 404 }); + return new Response('Not found', { status: 404 }); } /** Called by the runtime when a hibernated WebSocket receives a message. */ - async webSocketMessage( - _ws: WebSocket, - _message: string | ArrayBuffer, - ): Promise { + async webSocketMessage(_ws: WebSocket, _message: string | ArrayBuffer): Promise { // Status WebSocket is server-push only — ignore client messages. } @@ -383,7 +364,7 @@ export class TownDO extends DurableObject { ws: WebSocket, _code: number, _reason: string, - _wasClean: boolean, + _wasClean: boolean ): Promise { try { ws.close(); @@ -395,7 +376,7 @@ export class TownDO extends DurableObject { /** Called by the runtime when a hibernated WebSocket errors. */ async webSocketError(ws: WebSocket, _error: unknown): Promise { try { - ws.close(1011, "WebSocket error"); + ws.close(1011, 'WebSocket error'); } catch { // Already closed } @@ -405,10 +386,8 @@ export class TownDO extends DurableObject { * Broadcast the alarm status snapshot to all connected status WebSocket * clients. Called at the end of each alarm tick. */ - private broadcastAlarmStatus( - snapshot: Awaited>, - ): void { - const sockets = this.ctx.getWebSockets("status"); + private broadcastAlarmStatus(snapshot: Awaited>): void { + const sockets = this.ctx.getWebSockets('status'); if (sockets.length === 0) return; const payload = JSON.stringify(snapshot); @@ -426,11 +405,11 @@ export class TownDO extends DurableObject { * WebSocket clients. Called whenever an agent updates its status message. */ private broadcastAgentStatus(agentId: string, message: string): void { - const sockets = this.ctx.getWebSockets("status"); + const sockets = this.ctx.getWebSockets('status'); if (sockets.length === 0) return; const payload = JSON.stringify({ - type: "agent_status", + type: 'agent_status', agentId, message, timestamp: now(), @@ -449,20 +428,16 @@ export class TownDO extends DurableObject { * WebSocket clients. Called after bead create/update/close operations. */ private broadcastBeadEvent(event: { - type: - | "bead.created" - | "bead.status_changed" - | "bead.closed" - | "bead.failed"; + type: 'bead.created' | 'bead.status_changed' | 'bead.closed' | 'bead.failed'; beadId: string; title?: string; status?: string; rigId?: string; convoyId?: string; }): void { - const sockets = this.ctx.getWebSockets("status"); + const sockets = this.ctx.getWebSockets('status'); if (sockets.length === 0) return; - const frame = JSON.stringify({ channel: "bead", ...event, ts: now() }); + const frame = JSON.stringify({ channel: 'bead', ...event, ts: now() }); for (const ws of sockets) { try { ws.send(frame); @@ -476,15 +451,11 @@ export class TownDO extends DurableObject { * Broadcast convoy progress to all connected status WebSocket clients. * Called from onBeadClosed() after updating closed_beads count. */ - private broadcastConvoyProgress( - convoyId: string, - totalBeads: number, - closedBeads: number, - ): void { - const sockets = this.ctx.getWebSockets("status"); + private broadcastConvoyProgress(convoyId: string, totalBeads: number, closedBeads: number): void { + const sockets = this.ctx.getWebSockets('status'); if (sockets.length === 0) return; const frame = JSON.stringify({ - channel: "convoy", + channel: 'convoy', convoyId, totalBeads, closedBeads, @@ -504,9 +475,9 @@ export class TownDO extends DurableObject { * Called by the mayor via the /mayor/ui-action HTTP route. */ async broadcastUiAction(action: UiAction): Promise { - const sockets = this.ctx.getWebSockets("status"); + const sockets = this.ctx.getWebSockets('status'); if (sockets.length === 0) return; - const frame = JSON.stringify({ channel: "ui_action", action, ts: now() }); + const frame = JSON.stringify({ channel: 'ui_action', action, ts: now() }); for (const ws of sockets) { try { ws.send(frame); @@ -527,7 +498,7 @@ export class TownDO extends DurableObject { private async initializeDatabase(): Promise { // Load persisted town ID if available - const storedId = await this.ctx.storage.get("town:id"); + const storedId = await this.ctx.storage.get('town:id'); if (storedId) this._townId = storedId; // Cache owner_user_id for analytics events @@ -579,7 +550,7 @@ export class TownDO extends DurableObject { */ async setTownId(townId: string): Promise { this._townId = townId; - await this.ctx.storage.put("town:id", townId); + await this.ctx.storage.put('town:id', townId); } async setDashboardContext(context: string): Promise { @@ -618,7 +589,7 @@ export class TownDO extends DurableObject { */ async forceRefreshContainerToken(): Promise { const townId = this.townId; - if (!townId) throw new Error("townId not set"); + if (!townId) throw new Error('townId not set'); const townConfig = await this.getTownConfig(); const userId = townConfig.owner_user_id ?? townId; await dispatch.forceRefreshContainerToken(this.env, townId, userId); @@ -639,16 +610,13 @@ export class TownDO extends DurableObject { // Map config fields to their container env var equivalents. // When a value is set, push it; when cleared, remove it. const envMapping: Array<[string, string | undefined]> = [ - ["GIT_TOKEN", townConfig.git_auth?.github_token], - ["GITLAB_TOKEN", townConfig.git_auth?.gitlab_token], - ["GITLAB_INSTANCE_URL", townConfig.git_auth?.gitlab_instance_url], - ["GITHUB_CLI_PAT", townConfig.github_cli_pat], - ["GASTOWN_GIT_AUTHOR_NAME", townConfig.git_author_name], - ["GASTOWN_GIT_AUTHOR_EMAIL", townConfig.git_author_email], - [ - "GASTOWN_DISABLE_AI_COAUTHOR", - townConfig.disable_ai_coauthor ? "1" : undefined, - ], + ['GIT_TOKEN', townConfig.git_auth?.github_token], + ['GITLAB_TOKEN', townConfig.git_auth?.gitlab_token], + ['GITLAB_INSTANCE_URL', townConfig.git_auth?.gitlab_instance_url], + ['GITHUB_CLI_PAT', townConfig.github_cli_pat], + ['GASTOWN_GIT_AUTHOR_NAME', townConfig.git_author_name], + ['GASTOWN_GIT_AUTHOR_EMAIL', townConfig.git_author_email], + ['GASTOWN_DISABLE_AI_COAUTHOR', townConfig.disable_ai_coauthor ? '1' : undefined], ]; for (const [key, value] of envMapping) { @@ -659,10 +627,7 @@ export class TownDO extends DurableObject { await container.deleteEnvVar(key); } } catch (err) { - console.warn( - `[Town.do] syncConfigToContainer: ${key} sync failed:`, - err, - ); + console.warn(`[Town.do] syncConfigToContainer: ${key} sync failed:`, err); } } } @@ -690,7 +655,7 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ?`, - [rigId], + [rigId] ), ]); for (const { bead_id } of rigBeads) { @@ -710,39 +675,28 @@ export class TownDO extends DurableObject { async configureRig(rigConfig: RigConfig): Promise { console.log( - `${TOWN_LOG} configureRig: rigId=${rigConfig.rigId} hasKilocodeToken=${!!rigConfig.kilocodeToken}`, + `${TOWN_LOG} configureRig: rigId=${rigConfig.rigId} hasKilocodeToken=${!!rigConfig.kilocodeToken}` ); await this.ctx.storage.put(`rig:${rigConfig.rigId}:config`, rigConfig); if (rigConfig.kilocodeToken) { const townConfig = await this.getTownConfig(); - if ( - !townConfig.kilocode_token || - townConfig.kilocode_token !== rigConfig.kilocodeToken - ) { - console.log( - `${TOWN_LOG} configureRig: propagating kilocodeToken to town config`, - ); + if (!townConfig.kilocode_token || townConfig.kilocode_token !== rigConfig.kilocodeToken) { + console.log(`${TOWN_LOG} configureRig: propagating kilocodeToken to town config`); await this.updateTownConfig({ kilocode_token: rigConfig.kilocodeToken, }); } } - const token = - rigConfig.kilocodeToken ?? (await this.resolveKilocodeToken()); + const token = rigConfig.kilocodeToken ?? (await this.resolveKilocodeToken()); if (token) { try { const container = getTownContainerStub(this.env, this.townId); - await container.setEnvVar("KILOCODE_TOKEN", token); - console.log( - `${TOWN_LOG} configureRig: stored KILOCODE_TOKEN on TownContainerDO`, - ); + await container.setEnvVar('KILOCODE_TOKEN', token); + console.log(`${TOWN_LOG} configureRig: stored KILOCODE_TOKEN on TownContainerDO`); } catch (err) { - console.warn( - `${TOWN_LOG} configureRig: failed to store token on container DO:`, - err, - ); + console.warn(`${TOWN_LOG} configureRig: failed to store token on container DO:`, err); } } @@ -750,7 +704,7 @@ export class TownDO extends DurableObject { await this.armAlarmIfNeeded(); try { const container = getTownContainerStub(this.env, this.townId); - await container.fetch("http://container/health"); + await container.fetch('http://container/health'); } catch { // Container may take a moment to start — the alarm will retry } @@ -758,11 +712,8 @@ export class TownDO extends DurableObject { // Proactively clone the rig's repo and create a browse worktree so // the mayor has immediate access to the codebase without waiting for // the first agent dispatch. - this.setupRigRepoInContainer(rigConfig).catch((err) => - console.warn( - `${TOWN_LOG} configureRig: background repo setup failed:`, - err, - ), + this.setupRigRepoInContainer(rigConfig).catch(err => + console.warn(`${TOWN_LOG} configureRig: background repo setup failed:`, err) ); } @@ -789,16 +740,13 @@ export class TownDO extends DurableObject { envVars.KILOCODE_TOKEN = kilocodeToken; } - const containerConfig = await config.buildContainerConfig( - this.ctx.storage, - this.env, - ); + const containerConfig = await config.buildContainerConfig(this.ctx.storage, this.env); const container = getTownContainerStub(this.env, this.townId); - const response = await container.fetch("http://container/repos/setup", { - method: "POST", + const response = await container.fetch('http://container/repos/setup', { + method: 'POST', headers: { - "Content-Type": "application/json", - "X-Town-Config": JSON.stringify(containerConfig), + 'Content-Type': 'application/json', + 'X-Town-Config': JSON.stringify(containerConfig), }, body: JSON.stringify({ rigId: rigConfig.rigId, @@ -810,21 +758,17 @@ export class TownDO extends DurableObject { }); if (!response.ok) { - const text = await response.text().catch(() => "(unreadable)"); + const text = await response.text().catch(() => '(unreadable)'); console.warn( - `${TOWN_LOG} setupRigRepoInContainer: failed for rig=${rigConfig.rigId}: ${response.status} ${text.slice(0, 200)}`, + `${TOWN_LOG} setupRigRepoInContainer: failed for rig=${rigConfig.rigId}: ${response.status} ${text.slice(0, 200)}` ); } else { - console.log( - `${TOWN_LOG} setupRigRepoInContainer: accepted for rig=${rigConfig.rigId}`, - ); + console.log(`${TOWN_LOG} setupRigRepoInContainer: accepted for rig=${rigConfig.rigId}`); } } async getRigConfig(rigId: string): Promise { - return ( - (await this.ctx.storage.get(`rig:${rigId}:config`)) ?? null - ); + return (await this.ctx.storage.get(`rig:${rigId}:config`)) ?? null; } // ══════════════════════════════════════════════════════════════════ @@ -834,14 +778,14 @@ export class TownDO extends DurableObject { async createBead(input: CreateBeadInput): Promise { const bead = beadOps.createBead(this.sql, input); this.emitEvent({ - event: "bead.created", + event: 'bead.created', townId: this.townId, rigId: input.rig_id, beadId: bead.bead_id, beadType: input.type, }); this.broadcastBeadEvent({ - type: "bead.created", + type: 'bead.created', beadId: bead.bead_id, title: bead.title, status: bead.status, @@ -858,15 +802,11 @@ export class TownDO extends DurableObject { return beadOps.listBeads(this.sql, filter); } - async updateBeadStatus( - beadId: string, - status: string, - agentId: string, - ): Promise { + async updateBeadStatus(beadId: string, status: string, agentId: string): Promise { // Record terminal transitions as bead_cancelled events for the reconciler. // Non-terminal transitions are normal lifecycle changes, not cancellations. - if (status === "closed" || status === "failed") { - events.insertEvent(this.sql, "bead_cancelled", { + if (status === 'closed' || status === 'failed') { + events.insertEvent(this.sql, 'bead_cancelled', { bead_id: beadId, payload: { cancel_status: status }, }); @@ -876,10 +816,10 @@ export class TownDO extends DurableObject { // when the bead reaches a terminal status (closed/failed). const bead = beadOps.updateBeadStatus(this.sql, beadId, status, agentId); - if (status === "closed") { + if (status === 'closed') { const durationMs = Date.now() - new Date(bead.created_at).getTime(); this.emitEvent({ - event: "bead.closed", + event: 'bead.closed', townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, @@ -887,33 +827,33 @@ export class TownDO extends DurableObject { durationMs, }); this.broadcastBeadEvent({ - type: "bead.closed", + type: 'bead.closed', beadId, title: bead.title, - status: "closed", + status: 'closed', rigId: bead.rig_id ?? undefined, }); // When a bead closes, check if any blocked beads are now unblocked and dispatch them. this.dispatchUnblockedBeads(beadId); - } else if (status === "failed") { + } else if (status === 'failed') { this.emitEvent({ - event: "bead.failed", + event: 'bead.failed', townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, beadType: bead.type, }); this.broadcastBeadEvent({ - type: "bead.failed", + type: 'bead.failed', beadId, title: bead.title, - status: "failed", + status: 'failed', rigId: bead.rig_id ?? undefined, }); this.dispatchUnblockedBeads(beadId); } else { this.emitEvent({ - event: "bead.status_changed", + event: 'bead.status_changed', townId: this.townId, rigId: bead.rig_id ?? undefined, beadId, @@ -921,7 +861,7 @@ export class TownDO extends DurableObject { label: status, }); this.broadcastBeadEvent({ - type: "bead.status_changed", + type: 'bead.status_changed', beadId, title: bead.title, status, @@ -933,7 +873,7 @@ export class TownDO extends DurableObject { } async closeBead(beadId: string, agentId: string): Promise { - return this.updateBeadStatus(beadId, "closed", agentId); + return this.updateBeadStatus(beadId, 'closed', agentId); } async deleteBead(beadId: string): Promise { @@ -963,12 +903,12 @@ export class TownDO extends DurableObject { status: BeadStatus; metadata: Record; }>, - actorId: string, + actorId: string ): Promise { const bead = beadOps.updateBeadFields(this.sql, beadId, fields, actorId); // When a bead closes via field update, check for newly unblocked beads - if (fields.status === "closed" || fields.status === "failed") { + if (fields.status === 'closed' || fields.status === 'failed') { this.dispatchUnblockedBeads(beadId); } @@ -989,25 +929,25 @@ export class TownDO extends DurableObject { if (hookedBeadId) { // Return the bead to 'open' so the scheduler can re-assign it const bead = beadOps.getBead(this.sql, hookedBeadId); - if (bead && bead.status !== "closed" && bead.status !== "failed") { - beadOps.updateBeadStatus(this.sql, hookedBeadId, "open", agentId); + if (bead && bead.status !== 'closed' && bead.status !== 'failed') { + beadOps.updateBeadStatus(this.sql, hookedBeadId, 'open', agentId); } beadOps.logBeadEvent(this.sql, { beadId: hookedBeadId, agentId, - eventType: "unhooked", - newValue: "open", - metadata: { reason: "agent_reset", actor: "mayor" }, + eventType: 'unhooked', + newValue: 'open', + metadata: { reason: 'agent_reset', actor: 'mayor' }, }); agents.unhookBead(this.sql, agentId); } - agents.updateAgentStatus(this.sql, agentId, "idle"); + agents.updateAgentStatus(this.sql, agentId, 'idle'); console.log( - `${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? "none"}`, + `${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? 'none'}` ); } @@ -1017,7 +957,7 @@ export class TownDO extends DurableObject { */ async updateConvoy( convoyId: string, - fields: Partial<{ merge_mode: ConvoyMergeMode; feature_branch: string }>, + fields: Partial<{ merge_mode: ConvoyMergeMode; feature_branch: string }> ): Promise { const convoy = this.getConvoy(convoyId); if (!convoy) return null; @@ -1039,8 +979,8 @@ export class TownDO extends DurableObject { // Dynamic SET clause — query() can't statically verify param count here, // so use sql.exec() directly. The guard above guarantees values is non-empty. this.sql.exec( - /* sql */ `UPDATE ${convoy_metadata} SET ${setClauses.join(", ")} WHERE ${convoy_metadata.bead_id} = ?`, - ...values, + /* sql */ `UPDATE ${convoy_metadata} SET ${setClauses.join(', ')} WHERE ${convoy_metadata.bead_id} = ?`, + ...values ); // Also update the convoy bead's updated_at @@ -1051,7 +991,7 @@ export class TownDO extends DurableObject { SET ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), convoyId], + [now(), convoyId] ); } @@ -1111,20 +1051,12 @@ export class TownDO extends DurableObject { // ── Agent Events (delegated to AgentDO) ─────────────────────────── - async appendAgentEvent( - agentId: string, - eventType: string, - data: unknown, - ): Promise { + async appendAgentEvent(agentId: string, eventType: string, data: unknown): Promise { const agentDO = getAgentDOStub(this.env, agentId); return agentDO.appendEvent(eventType, data); } - async getAgentEvents( - agentId: string, - afterId?: number, - limit?: number, - ): Promise { + async getAgentEvents(agentId: string, afterId?: number, limit?: number): Promise { const agentDO = getAgentDOStub(this.env, agentId); return agentDO.getEvents(afterId, limit); } @@ -1151,16 +1083,13 @@ export class TownDO extends DurableObject { lastEventType?: string | null; lastEventAt?: string | null; activeTools?: string[]; - }, + } ): Promise { agents.touchAgent(this.sql, agentId, watermark); await this.armAlarmIfNeeded(); } - async updateAgentStatusMessage( - agentId: string, - message: string, - ): Promise { + async updateAgentStatusMessage(agentId: string, message: string): Promise { agents.updateAgentStatusMessage(this.sql, agentId, message); const agent = agents.getAgent(this.sql, agentId); if (agent?.current_hook_bead_id) { @@ -1168,7 +1097,7 @@ export class TownDO extends DurableObject { beadOps.logBeadEvent(this.sql, { beadId: agent.current_hook_bead_id, agentId, - eventType: "agent_status", + eventType: 'agent_status', newValue: message, metadata: { agentId, @@ -1186,7 +1115,7 @@ export class TownDO extends DurableObject { async setAgentDispatchAttempts( agentId: string, attempts: number, - lastActivityAt?: string, + lastActivityAt?: string ): Promise { query( this.sql, @@ -1196,7 +1125,7 @@ export class TownDO extends DurableObject { ${agent_metadata.columns.last_activity_at} = COALESCE(?, ${agent_metadata.columns.last_activity_at}) WHERE ${agent_metadata.bead_id} = ? `, - [attempts, lastActivityAt ?? null, agentId], + [attempts, lastActivityAt ?? null, agentId] ); } @@ -1225,25 +1154,25 @@ export class TownDO extends DurableObject { agentId: string, message: string, options?: { - mode?: "wait-idle" | "immediate" | "queue"; - priority?: "normal" | "urgent"; + mode?: 'wait-idle' | 'immediate' | 'queue'; + priority?: 'normal' | 'urgent'; source?: string; ttlSeconds?: number; - }, + } ): Promise { const nudgeId = crypto.randomUUID(); - const mode = options?.mode ?? "wait-idle"; - const priority = options?.priority ?? "normal"; - const source = options?.source ?? "system"; + const mode = options?.mode ?? 'wait-idle'; + const priority = options?.priority ?? 'normal'; + const source = options?.source ?? 'system'; let expiresAt: string | null = null; - if (mode === "queue" && options?.ttlSeconds != null) { + if (mode === 'queue' && options?.ttlSeconds != null) { // Use SQLite-compatible datetime format (space separator, no Z suffix) so // comparisons against datetime('now') work correctly. expiresAt = new Date(Date.now() + options.ttlSeconds * 1000) .toISOString() - .replace("T", " ") - .replace("Z", ""); + .replace('T', ' ') + .replace('Z', ''); } query( @@ -1259,20 +1188,15 @@ export class TownDO extends DurableObject { ${agent_nudges.columns.expires_at} ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - [nudgeId, agentId, message, mode, priority, source, expiresAt], + [nudgeId, agentId, message, mode, priority, source, expiresAt] ); console.log( - `${TOWN_LOG} queueNudge: nudge_id=${nudgeId} agent=${agentId} mode=${mode} priority=${priority} source=${source}`, + `${TOWN_LOG} queueNudge: nudge_id=${nudgeId} agent=${agentId} mode=${mode} priority=${priority} source=${source}` ); - if (mode === "immediate") { - const sent = await dispatch.sendMessageToAgent( - this.env, - this.townId, - agentId, - message, - ); + if (mode === 'immediate') { + const sent = await dispatch.sendMessageToAgent(this.env, this.townId, agentId, message); if (sent) { query( this.sql, @@ -1281,14 +1205,12 @@ export class TownDO extends DurableObject { SET ${agent_nudges.columns.delivered_at} = datetime('now') WHERE ${agent_nudges.nudge_id} = ? `, - [nudgeId], - ); - console.log( - `${TOWN_LOG} queueNudge: immediate nudge delivered to agent=${agentId}`, + [nudgeId] ); + console.log(`${TOWN_LOG} queueNudge: immediate nudge delivered to agent=${agentId}`); } else { console.warn( - `${TOWN_LOG} queueNudge: immediate delivery failed for agent=${agentId}, nudge queued for retry`, + `${TOWN_LOG} queueNudge: immediate delivery failed for agent=${agentId}, nudge queued for retry` ); } } @@ -1327,7 +1249,7 @@ export class TownDO extends DurableObject { CASE ${agent_nudges.priority} WHEN 'urgent' THEN 0 ELSE 1 END ASC, ${agent_nudges.created_at} ASC `, - [agentId], + [agentId] ), ]; @@ -1351,7 +1273,7 @@ export class TownDO extends DurableObject { SET ${agent_nudges.columns.delivered_at} = datetime('now') WHERE ${agent_nudges.nudge_id} = ? `, - [nudgeId], + [nudgeId] ); } @@ -1371,7 +1293,7 @@ export class TownDO extends DurableObject { AND ${agent_nudges.delivered_at} IS NULL RETURNING ${agent_nudges.nudge_id} `, - [], + [] ), ]; @@ -1385,7 +1307,7 @@ export class TownDO extends DurableObject { async submitToReviewQueue(input: ReviewQueueInput): Promise { reviewQueue.submitToReviewQueue(this.sql, input); this.emitEvent({ - event: "review.submitted", + event: 'review.submitted', townId: this.townId, rigId: input.rig_id, beadId: input.bead_id, @@ -1397,16 +1319,13 @@ export class TownDO extends DurableObject { return reviewQueue.popReviewQueue(this.sql); } - async completeReview( - entryId: string, - status: "merged" | "failed", - ): Promise { + async completeReview(entryId: string, status: 'merged' | 'failed'): Promise { reviewQueue.completeReview(this.sql, entryId, status); } async completeReviewWithResult(input: { entry_id: string; - status: "merged" | "failed" | "conflict"; + status: 'merged' | 'failed' | 'conflict'; message?: string; commit_sha?: string; }): Promise { @@ -1414,15 +1333,13 @@ export class TownDO extends DurableObject { // trigger dispatchUnblockedBeads for it after the MR closes. const mrBead = beadOps.getBead(this.sql, input.entry_id); const sourceBeadId = - typeof mrBead?.metadata?.source_bead_id === "string" - ? mrBead.metadata.source_bead_id - : null; + typeof mrBead?.metadata?.source_bead_id === 'string' ? mrBead.metadata.source_bead_id : null; reviewQueue.completeReviewWithResult(this.sql, input); - if (input.status === "merged") { + if (input.status === 'merged') { this.emitEvent({ - event: "review.completed", + event: 'review.completed', townId: this.townId, beadId: input.entry_id, }); @@ -1432,9 +1349,9 @@ export class TownDO extends DurableObject { if (sourceBeadId) { this.dispatchUnblockedBeads(sourceBeadId); } - } else if (input.status === "failed" || input.status === "conflict") { + } else if (input.status === 'failed' || input.status === 'conflict') { this.emitEvent({ - event: "review.failed", + event: 'review.failed', townId: this.townId, beadId: input.entry_id, }); @@ -1451,7 +1368,7 @@ export class TownDO extends DurableObject { // applies all pending events before reconciliation runs. DO RPCs // are serialized, so agentCompleted can't race with this — it // waits for agentDone to finish before executing. - events.insertEvent(this.sql, "agent_done", { + events.insertEvent(this.sql, 'agent_done', { agent_id: agentId, payload: { branch: input.branch, @@ -1464,19 +1381,19 @@ export class TownDO extends DurableObject { async agentCompleted( agentId: string, - input: { status: "completed" | "failed"; reason?: string }, + input: { status: 'completed' | 'failed'; reason?: string } ): Promise { // Resolve empty agentId to mayor (backwards compat with container callback) let resolvedAgentId = agentId; if (!resolvedAgentId) { - const mayor = agents.listAgents(this.sql, { role: "mayor" })[0]; + const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0]; if (mayor) resolvedAgentId = mayor.id; } // Event-only: record the fact. The alarm's Phase 0 drains and // applies all pending events. DO RPCs are serialized so there's // no race with agentDone. - events.insertEvent(this.sql, "agent_completed", { + events.insertEvent(this.sql, 'agent_completed', { agent_id: resolvedAgentId || agentId, payload: { status: input.status, @@ -1488,7 +1405,7 @@ export class TownDO extends DurableObject { if (resolvedAgentId) { const agent = agents.getAgent(this.sql, resolvedAgentId); this.emitEvent({ - event: "agent.exited", + event: 'agent.exited', townId: this.townId, agentId: resolvedAgentId, role: agent?.role, @@ -1507,39 +1424,33 @@ export class TownDO extends DurableObject { */ async requestChanges( agentId: string, - input: { feedback: string; files?: string[] }, + input: { feedback: string; files?: string[] } ): Promise<{ rework_bead_id: string }> { const agent = agents.getAgent(this.sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); - if (agent.role !== "refinery") - throw new Error(`Only refineries can request changes`); - if (!agent.current_hook_bead_id) - throw new Error(`Agent ${agentId} is not hooked to a bead`); + if (agent.role !== 'refinery') throw new Error(`Only refineries can request changes`); + if (!agent.current_hook_bead_id) throw new Error(`Agent ${agentId} is not hooked to a bead`); const mrBead = beadOps.getBead(this.sql, agent.current_hook_bead_id); - if (!mrBead || mrBead.type !== "merge_request") { + if (!mrBead || mrBead.type !== 'merge_request') { throw new Error(`Agent ${agentId} is not hooked to a merge_request bead`); } // Find the source bead (the original issue the polecat worked on) const sourceBeadId = - typeof mrBead.metadata?.source_bead_id === "string" - ? mrBead.metadata.source_bead_id - : null; - const sourceBead = sourceBeadId - ? beadOps.getBead(this.sql, sourceBeadId) - : null; + typeof mrBead.metadata?.source_bead_id === 'string' ? mrBead.metadata.source_bead_id : null; + const sourceBead = sourceBeadId ? beadOps.getBead(this.sql, sourceBeadId) : null; // Get branch info from review_metadata const reviewMeta = reviewQueue.getReviewMetadata(this.sql, mrBead.bead_id); const reworkBead = beadOps.createBead(this.sql, { - type: "issue", + type: 'issue', title: `Rework: ${sourceBead?.title ?? mrBead.title}`, body: input.feedback, - priority: sourceBead?.priority ?? "medium", + priority: sourceBead?.priority ?? 'medium', rig_id: mrBead.rig_id ?? undefined, - labels: ["gt:rework"], + labels: ['gt:rework'], metadata: { rework_for: sourceBeadId, mr_bead_id: mrBead.bead_id, @@ -1550,29 +1461,24 @@ export class TownDO extends DurableObject { }); // Rework bead blocks the MR bead — MR can't proceed until rework is done - beadOps.insertDependency( - this.sql, - mrBead.bead_id, - reworkBead.bead_id, - "blocks", - ); + beadOps.insertDependency(this.sql, mrBead.bead_id, reworkBead.bead_id, 'blocks'); // Record event so the reconciler picks up the rework bead - events.insertEvent(this.sql, "bead_created", { + events.insertEvent(this.sql, 'bead_created', { bead_id: reworkBead.bead_id, - payload: { bead_type: "issue", rig_id: mrBead.rig_id }, + payload: { bead_type: 'issue', rig_id: mrBead.rig_id }, }); beadOps.logBeadEvent(this.sql, { beadId: mrBead.bead_id, agentId, - eventType: "rework_requested", + eventType: 'rework_requested', newValue: reworkBead.bead_id, metadata: { feedback: input.feedback.slice(0, 500), files: input.files }, }); console.log( - `${TOWN_LOG} requestChanges: refinery=${agentId} mr=${mrBead.bead_id} rework=${reworkBead.bead_id}`, + `${TOWN_LOG} requestChanges: refinery=${agentId} mr=${mrBead.bead_id} rework=${reworkBead.bead_id}` ); await this.armAlarmIfNeeded(); @@ -1592,29 +1498,25 @@ export class TownDO extends DurableObject { }): Promise { const triageBead = beadOps.getBead(this.sql, input.triage_request_bead_id); if (!triageBead) - throw new Error( - `Triage request bead ${input.triage_request_bead_id} not found`, - ); + throw new Error(`Triage request bead ${input.triage_request_bead_id} not found`); if (!triageBead.labels.includes(patrol.TRIAGE_REQUEST_LABEL)) { - throw new Error( - `Bead ${input.triage_request_bead_id} is not a triage request`, - ); + throw new Error(`Bead ${input.triage_request_bead_id} is not a triage request`); } - if (triageBead.status !== "open") { + if (triageBead.status !== 'open') { throw new Error( - `Triage request ${input.triage_request_bead_id} is already ${triageBead.status} — cannot resolve again`, + `Triage request ${input.triage_request_bead_id} is already ${triageBead.status} — cannot resolve again` ); } // ── Apply the chosen action ──────────────────────────────────── const targetAgentId = - typeof triageBead.metadata?.agent_bead_id === "string" + typeof triageBead.metadata?.agent_bead_id === 'string' ? triageBead.metadata.agent_bead_id : null; // Use the hooked bead ID captured when the triage request was created, // not the agent's current hook (which may have changed since then). const snapshotHookedBeadId = - typeof triageBead.metadata?.hooked_bead_id === "string" + typeof triageBead.metadata?.hooked_bead_id === 'string' ? triageBead.metadata.hooked_bead_id : null; const action = input.action.toUpperCase(); @@ -1623,24 +1525,19 @@ export class TownDO extends DurableObject { const targetAgent = agents.getAgent(this.sql, targetAgentId); switch (action) { - case "RESTART": - case "RESTART_WITH_BACKOFF": { + case 'RESTART': + case 'RESTART_WITH_BACKOFF': { // Stop the agent in the container, reset to idle so the // scheduler picks it up again on the next alarm cycle. - if ( - targetAgent?.status === "working" || - targetAgent?.status === "stalled" - ) { - dispatch - .stopAgentInContainer(this.env, this.townId, targetAgentId) - .catch(() => {}); + if (targetAgent?.status === 'working' || targetAgent?.status === 'stalled') { + dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); } if (targetAgent) { // RESTART clears last_activity_at so the scheduler picks it // up immediately. RESTART_WITH_BACKOFF sets it to now() so // the dispatch cooldown (DISPATCH_COOLDOWN_MS) delays the // next attempt, preventing immediate restart of crash loops. - const activityAt = action === "RESTART_WITH_BACKOFF" ? now() : null; + const activityAt = action === 'RESTART_WITH_BACKOFF' ? now() : null; query( this.sql, /* sql */ ` @@ -1649,93 +1546,72 @@ export class TownDO extends DurableObject { ${agent_metadata.columns.last_activity_at} = ? WHERE ${agent_metadata.bead_id} = ? `, - [activityAt, targetAgentId], + [activityAt, targetAgentId] ); } break; } - case "CLOSE_BEAD": { + case 'CLOSE_BEAD': { // Fail the bead that was hooked when the triage request was // created (not the agent's current hook, which may differ). - const beadToClose = - snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; + const beadToClose = snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; if (beadToClose) { - beadOps.updateBeadStatus( - this.sql, - beadToClose, - "failed", - input.agent_id, - ); + beadOps.updateBeadStatus(this.sql, beadToClose, 'failed', input.agent_id); // Only stop and unhook if the agent is still working on this // specific bead. If the agent has moved on, stopping it would // abort unrelated work. if (targetAgent?.current_hook_bead_id === beadToClose) { - if ( - targetAgent.status === "working" || - targetAgent.status === "stalled" - ) { - dispatch - .stopAgentInContainer(this.env, this.townId, targetAgentId) - .catch(() => {}); + if (targetAgent.status === 'working' || targetAgent.status === 'stalled') { + dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); } agents.unhookBead(this.sql, targetAgentId); } } break; } - case "ESCALATE_TO_MAYOR": - case "ESCALATE": { - const message = - input.resolution_notes || triageBead.title || "Triage escalation"; + case 'ESCALATE_TO_MAYOR': + case 'ESCALATE': { + const message = input.resolution_notes || triageBead.title || 'Triage escalation'; this.sendMayorMessage( - `[Triage Escalation] ${message}\n\nAgent: ${targetAgentId ?? "unknown"}\nBead: ${snapshotHookedBeadId ?? "unknown"}`, - ).catch((err) => - console.warn( - `${TOWN_LOG} resolveTriage: mayor notification failed:`, - err, - ), + `[Triage Escalation] ${message}\n\nAgent: ${targetAgentId ?? 'unknown'}\nBead: ${snapshotHookedBeadId ?? 'unknown'}` + ).catch(err => + console.warn(`${TOWN_LOG} resolveTriage: mayor notification failed:`, err) ); break; } - case "NUDGE": { + case 'NUDGE': { // Nudge the stuck agent — time-sensitive, deliver immediately if (targetAgent && targetAgentId) { this.queueNudge( targetAgentId, input.resolution_notes || - "The triage system has flagged you as potentially stuck. Please report your status.", - { mode: "immediate", source: "triage", priority: "urgent" }, - ).catch((err) => + 'The triage system has flagged you as potentially stuck. Please report your status.', + { mode: 'immediate', source: 'triage', priority: 'urgent' } + ).catch(err => console.warn( `${TOWN_LOG} resolveTriage: nudge failed for agent=${targetAgentId}:`, - err, - ), + err + ) ); this.emitEvent({ - event: "nudge.queued", + event: 'nudge.queued', townId: this.townId, agentId: targetAgentId, - label: "triage_nudge", + label: 'triage_nudge', }); } break; } - case "REASSIGN_BEAD": { + case 'REASSIGN_BEAD': { // Target the bead from the triage snapshot, not the agent's current hook. - const beadToReassign = - snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; + const beadToReassign = snapshotHookedBeadId ?? targetAgent?.current_hook_bead_id; if (beadToReassign) { // Only stop and unhook if the agent is still working on this // specific bead. If the agent has moved on, stopping it would // abort unrelated work. if (targetAgent?.current_hook_bead_id === beadToReassign) { - if ( - targetAgent.status === "working" || - targetAgent.status === "stalled" - ) { - dispatch - .stopAgentInContainer(this.env, this.townId, targetAgentId) - .catch(() => {}); + if (targetAgent.status === 'working' || targetAgent.status === 'stalled') { + dispatch.stopAgentInContainer(this.env, this.townId, targetAgentId).catch(() => {}); } agents.unhookBead(this.sql, targetAgentId); } @@ -1751,7 +1627,7 @@ export class TownDO extends DurableObject { AND ${beads.status} != 'closed' AND ${beads.status} != 'failed' `, - [now(), beadToReassign], + [now(), beadToReassign] ); } break; @@ -1788,15 +1664,15 @@ export class TownDO extends DurableObject { input.resolution_notes, input.agent_id, input.triage_request_bead_id, - ], + ] ); beadOps.logBeadEvent(this.sql, { beadId: input.triage_request_bead_id, agentId: input.agent_id, - eventType: "status_changed", + eventType: 'status_changed', oldValue: triageBead.status, - newValue: "closed", + newValue: 'closed', metadata: { action: input.action, resolution_notes: input.resolution_notes, @@ -1810,7 +1686,7 @@ export class TownDO extends DurableObject { beadOps.logBeadEvent(this.sql, { beadId: targetBeadId, agentId: input.agent_id, - eventType: "triage_resolved", + eventType: 'triage_resolved', newValue: action, metadata: { action, @@ -1826,29 +1702,21 @@ export class TownDO extends DurableObject { // The escalation_bead_id is nested under metadata.context (set by // createTriageRequest's TriageRequestMetadata structure). const ctx = - typeof triageBead.metadata?.context === "object" && - triageBead.metadata.context !== null + typeof triageBead.metadata?.context === 'object' && triageBead.metadata.context !== null ? (triageBead.metadata.context as Record) : null; const escalationBeadId = - typeof ctx?.escalation_bead_id === "string" - ? ctx.escalation_bead_id - : null; + typeof ctx?.escalation_bead_id === 'string' ? ctx.escalation_bead_id : null; if (escalationBeadId) { - beadOps.updateBeadStatus( - this.sql, - escalationBeadId, - "closed", - input.agent_id, - ); + beadOps.updateBeadStatus(this.sql, escalationBeadId, 'closed', input.agent_id); } console.log( - `${TOWN_LOG} resolveTriage: bead=${input.triage_request_bead_id} action=${input.action}`, + `${TOWN_LOG} resolveTriage: bead=${input.triage_request_bead_id} action=${input.action}` ); const updated = beadOps.getBead(this.sql, input.triage_request_bead_id); - if (!updated) throw new Error("Triage bead not found after update"); + if (!updated) throw new Error('Triage bead not found after update'); return updated; } @@ -1857,15 +1725,12 @@ export class TownDO extends DurableObject { } async getMoleculeCurrentStep( - agentId: string, + agentId: string ): Promise<{ molecule: Molecule; step: unknown } | null> { return reviewQueue.getMoleculeCurrentStep(this.sql, agentId); } - async advanceMoleculeStep( - agentId: string, - summary: string, - ): Promise { + async advanceMoleculeStep(agentId: string, summary: string): Promise { return reviewQueue.advanceMoleculeStep(this.sql, agentId, summary); } @@ -1881,28 +1746,23 @@ export class TownDO extends DurableObject { metadata?: Record; }): Promise<{ bead: Bead; agent: Agent }> { const createdBead = beadOps.createBead(this.sql, { - type: "issue", + type: 'issue', title: input.title, body: input.body, - priority: BeadPriority.catch("medium").parse(input.priority ?? "medium"), + priority: BeadPriority.catch('medium').parse(input.priority ?? 'medium'), rig_id: input.rigId, metadata: input.metadata, }); - events.insertEvent(this.sql, "bead_created", { + events.insertEvent(this.sql, 'bead_created', { bead_id: createdBead.bead_id, - payload: { bead_type: "issue", rig_id: input.rigId, has_blockers: false }, + payload: { bead_type: 'issue', rig_id: input.rigId, has_blockers: false }, }); // Fast path: assign agent immediately for UX ("Toast is on it!") // rather than waiting for the next alarm tick. Uses the same // getOrCreateAgent + hookBead path the reconciler would use. - const agent = agents.getOrCreateAgent( - this.sql, - "polecat", - input.rigId, - this.townId, - ); + 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) @@ -1911,11 +1771,8 @@ export class TownDO extends DurableObject { // 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, - ), + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) ); await this.armAlarmIfNeeded(); return { bead, agent: hookedAgent }; @@ -1932,7 +1789,7 @@ export class TownDO extends DurableObject { > { const rigRecords = rigs.listRigs(this.sql); return Promise.all( - rigRecords.map(async (r) => { + rigRecords.map(async r => { const rc = await this.getRigConfig(r.id); return { rigId: r.id, @@ -1940,7 +1797,7 @@ export class TownDO extends DurableObject { defaultBranch: r.default_branch, platformIntegrationId: rc?.platformIntegrationId, }; - }), + }) ); } @@ -1951,34 +1808,28 @@ export class TownDO extends DurableObject { async sendMayorMessage( message: string, _model?: string, - uiContext?: string, + uiContext?: string ): Promise<{ agentId: string; - sessionStatus: "idle" | "active" | "starting"; + sessionStatus: 'idle' | 'active' | 'starting'; }> { const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; + let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; mayor = agents.registerAgent(this.sql, { - role: "mayor", - name: "mayor", + role: 'mayor', + name: 'mayor', identity, }); } - const containerStatus = await dispatch.checkAgentContainerStatus( - this.env, - townId, - mayor.id, - ); - const isAlive = - containerStatus.status === "running" || - containerStatus.status === "starting"; + const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); + const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; console.log( - `${TOWN_LOG} sendMayorMessage: townId=${townId} mayorId=${mayor.id} containerStatus=${containerStatus.status} isAlive=${isAlive}`, + `${TOWN_LOG} sendMayorMessage: townId=${townId} mayorId=${mayor.id} containerStatus=${containerStatus.status} isAlive=${isAlive}` ); const effectiveContext = uiContext ?? this._dashboardContext; @@ -1986,62 +1837,53 @@ export class TownDO extends DurableObject { ? `\n${effectiveContext}\n\n\n${message}` : message; - let sessionStatus: "idle" | "active" | "starting"; + let sessionStatus: 'idle' | 'active' | 'starting'; if (isAlive) { - const sent = await dispatch.sendMessageToAgent( - this.env, - townId, - mayor.id, - combinedMessage, - ); - sessionStatus = sent ? "active" : "idle"; + const sent = await dispatch.sendMessageToAgent(this.env, townId, mayor.id, combinedMessage); + sessionStatus = sent ? 'active' : 'idle'; } else { const townConfig = await this.getTownConfig(); const rigConfig = await this.getMayorRigConfig(); const kilocodeToken = await this.resolveKilocodeToken(); console.log( - `${TOWN_LOG} sendMayorMessage: townId=${townId} hasRigConfig=${!!rigConfig} hasKilocodeToken=${!!kilocodeToken} townConfigToken=${!!townConfig.kilocode_token} rigConfigToken=${!!rigConfig?.kilocodeToken}`, + `${TOWN_LOG} sendMayorMessage: townId=${townId} hasRigConfig=${!!rigConfig} hasKilocodeToken=${!!kilocodeToken} townConfigToken=${!!townConfig.kilocode_token} rigConfigToken=${!!rigConfig?.kilocodeToken}` ); if (kilocodeToken) { try { const containerStub = getTownContainerStub(this.env, townId); - await containerStub.setEnvVar("KILOCODE_TOKEN", kilocodeToken); + await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); } catch { // Best effort } } - const started = await dispatch.startAgentInContainer( - this.env, - this.ctx.storage, - { - townId, - rigId: `mayor-${townId}`, - userId: townConfig.owner_user_id ?? rigConfig?.userId ?? townId, - agentId: mayor.id, - agentName: "mayor", - role: "mayor", - identity: mayor.identity, - beadId: "", - beadTitle: message, - beadBody: "", - checkpoint: null, - gitUrl: rigConfig?.gitUrl ?? "", - defaultBranch: rigConfig?.defaultBranch ?? "main", - kilocodeToken, - townConfig, - rigs: await this.rigListForMayor(), - }, - ); + const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { + townId, + rigId: `mayor-${townId}`, + userId: townConfig.owner_user_id ?? rigConfig?.userId ?? townId, + agentId: mayor.id, + agentName: 'mayor', + role: 'mayor', + identity: mayor.identity, + beadId: '', + beadTitle: message, + beadBody: '', + checkpoint: null, + gitUrl: rigConfig?.gitUrl ?? '', + defaultBranch: rigConfig?.defaultBranch ?? 'main', + kilocodeToken, + townConfig, + rigs: await this.rigListForMayor(), + }); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, "working"); - sessionStatus = "starting"; + agents.updateAgentStatus(this.sql, mayor.id, 'working'); + sessionStatus = 'starting'; } else { - sessionStatus = "idle"; + sessionStatus = 'idle'; } } @@ -2056,36 +1898,27 @@ export class TownDO extends DurableObject { */ async ensureMayor(): Promise<{ agentId: string; - sessionStatus: "idle" | "active" | "starting"; + sessionStatus: 'idle' | 'active' | 'starting'; }> { const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; + let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; mayor = agents.registerAgent(this.sql, { - role: "mayor", - name: "mayor", + role: 'mayor', + name: 'mayor', identity, }); console.log(`${TOWN_LOG} ensureMayor: created mayor agent ${mayor.id}`); } // Check if the container is already running - const containerStatus = await dispatch.checkAgentContainerStatus( - this.env, - townId, - mayor.id, - ); - const isAlive = - containerStatus.status === "running" || - containerStatus.status === "starting"; + const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); + const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; if (isAlive) { - const status = - mayor.status === "working" || mayor.status === "stalled" - ? "active" - : "idle"; + const status = mayor.status === 'working' || mayor.status === 'stalled' ? 'active' : 'idle'; return { agentId: mayor.id, sessionStatus: status }; } @@ -2099,54 +1932,45 @@ export class TownDO extends DurableObject { // will retry via status polling once a rig is created and the token // becomes available. if (!kilocodeToken) { - console.warn( - `${TOWN_LOG} ensureMayor: no kilocodeToken available, deferring start`, - ); - return { agentId: mayor.id, sessionStatus: "idle" }; + console.warn(`${TOWN_LOG} ensureMayor: no kilocodeToken available, deferring start`); + return { agentId: mayor.id, sessionStatus: 'idle' }; } try { const containerStub = getTownContainerStub(this.env, townId); - await containerStub.setEnvVar("KILOCODE_TOKEN", kilocodeToken); + await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); } catch { // Best effort } // Start with an empty prompt — the mayor will be idle but its container // and SDK server will be running, ready for PTY connections. - const started = await dispatch.startAgentInContainer( - this.env, - this.ctx.storage, - { - townId, - rigId: `mayor-${townId}`, - userId: - townConfig.owner_user_id ?? - rigConfig?.userId ?? - townConfig.created_by_user_id ?? - townId, - agentId: mayor.id, - agentName: "mayor", - role: "mayor", - identity: mayor.identity, - beadId: "", - beadTitle: "Mayor ready. Waiting for instructions.", - beadBody: "", - checkpoint: null, - gitUrl: rigConfig?.gitUrl ?? "", - defaultBranch: rigConfig?.defaultBranch ?? "main", - kilocodeToken, - townConfig, - rigs: await this.rigListForMayor(), - }, - ); + const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { + townId, + rigId: `mayor-${townId}`, + userId: + townConfig.owner_user_id ?? rigConfig?.userId ?? townConfig.created_by_user_id ?? townId, + agentId: mayor.id, + agentName: 'mayor', + role: 'mayor', + identity: mayor.identity, + beadId: '', + beadTitle: 'Mayor ready. Waiting for instructions.', + beadBody: '', + checkpoint: null, + gitUrl: rigConfig?.gitUrl ?? '', + defaultBranch: rigConfig?.defaultBranch ?? 'main', + kilocodeToken, + townConfig, + rigs: await this.rigListForMayor(), + }); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, "working"); - return { agentId: mayor.id, sessionStatus: "starting" }; + agents.updateAgentStatus(this.sql, mayor.id, 'working'); + return { agentId: mayor.id, sessionStatus: 'starting' }; } - return { agentId: mayor.id, sessionStatus: "idle" }; + return { agentId: mayor.id, sessionStatus: 'idle' }; } async getMayorStatus(): Promise<{ @@ -2155,20 +1979,20 @@ export class TownDO extends DurableObject { session: { agentId: string; sessionId: string; - status: "idle" | "active" | "starting"; + status: 'idle' | 'active' | 'starting'; lastActivityAt: string; } | null; }> { - const mayor = agents.listAgents(this.sql, { role: "mayor" })[0] ?? null; + const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; - const mapStatus = (agentStatus: string): "idle" | "active" | "starting" => { + const mapStatus = (agentStatus: string): 'idle' | 'active' | 'starting' => { switch (agentStatus) { - case "working": - return "active"; - case "stalled": - return "active"; + case 'working': + return 'active'; + case 'stalled': + return 'active'; default: - return "idle"; + return 'idle'; } }; @@ -2220,11 +2044,7 @@ export class TownDO extends DurableObject { const parsed = z .object({ title: z.string().min(1), - beads: z - .array( - z.object({ bead_id: z.string().min(1), rig_id: z.string().min(1) }), - ) - .min(1), + beads: z.array(z.object({ bead_id: z.string().min(1), rig_id: z.string().min(1) })).min(1), created_by: z.string().min(1).optional(), }) .parse(input); @@ -2247,21 +2067,21 @@ export class TownDO extends DurableObject { `, [ convoyId, - "convoy", - "open", + 'convoy', + 'open', parsed.title, null, null, null, null, - "medium", - JSON.stringify(["gt:convoy"]), - "{}", + 'medium', + JSON.stringify(['gt:convoy']), + '{}', parsed.created_by ?? null, timestamp, timestamp, null, - ], + ] ); // Create convoy_metadata @@ -2273,7 +2093,7 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.closed_beads}, ${convoy_metadata.columns.landed_at} ) VALUES (?, ?, ?, ?) `, - [convoyId, parsed.beads.length, 0, null], + [convoyId, parsed.beads.length, 0, null] ); // Track beads via bead_dependencies @@ -2287,24 +2107,21 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [bead.bead_id, convoyId, "tracks"], + [bead.bead_id, convoyId, 'tracks'] ); } const convoy = this.getConvoy(convoyId); - if (!convoy) throw new Error("Failed to create convoy"); + if (!convoy) throw new Error('Failed to create convoy'); this.emitEvent({ - event: "convoy.created", + event: 'convoy.created', townId: this.townId, convoyId, }); return convoy; } - async onBeadClosed(input: { - convoyId: string; - beadId: string; - }): Promise { + async onBeadClosed(input: { convoyId: string; beadId: string }): Promise { // Count closed tracked beads const closedRows = [ ...query( @@ -2316,12 +2133,10 @@ export class TownDO extends DurableObject { AND ${bead_dependencies.dependency_type} = 'tracks' AND ${beads.status} = 'closed' `, - [input.convoyId], + [input.convoyId] ), ]; - const closedCount = z - .object({ count: z.number() }) - .parse(closedRows[0] ?? { count: 0 }).count; + const closedCount = z.object({ count: z.number() }).parse(closedRows[0] ?? { count: 0 }).count; query( this.sql, @@ -2330,22 +2145,14 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.closed_beads} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [closedCount, input.convoyId], + [closedCount, input.convoyId] ); const convoy = this.getConvoy(input.convoyId); if (convoy) { - this.broadcastConvoyProgress( - input.convoyId, - convoy.total_beads, - convoy.closed_beads, - ); + this.broadcastConvoyProgress(input.convoyId, convoy.total_beads, convoy.closed_beads); } - if ( - convoy && - convoy.status === "active" && - convoy.closed_beads >= convoy.total_beads - ) { + if (convoy && convoy.status === 'active' && convoy.closed_beads >= convoy.total_beads) { const timestamp = now(); query( this.sql, @@ -2354,7 +2161,7 @@ export class TownDO extends DurableObject { SET ${beads.columns.status} = 'closed', ${beads.columns.closed_at} = ?, ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, input.convoyId], + [timestamp, timestamp, input.convoyId] ); query( this.sql, @@ -2363,10 +2170,10 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [timestamp, input.convoyId], + [timestamp, input.convoyId] ); this.emitEvent({ - event: "convoy.landed", + event: 'convoy.landed', townId: this.townId, convoyId: input.convoyId, }); @@ -2396,7 +2203,7 @@ export class TownDO extends DurableObject { WHERE ${bead_dependencies.depends_on_bead_id} = ? AND ${bead_dependencies.dependency_type} = 'tracks' `, - [convoyId], + [convoyId] ), ]; @@ -2408,7 +2215,7 @@ export class TownDO extends DurableObject { for (const raw of trackedRows) { const row = TrackedRow.parse(raw); - if (row.status === "closed" || row.status === "failed") continue; + if (row.status === 'closed' || row.status === 'failed') continue; // Unhook agent if still assigned if (row.assignee_agent_bead_id) { @@ -2417,18 +2224,18 @@ export class TownDO extends DurableObject { } catch (err) { console.warn( `${TOWN_LOG} closeConvoy: unhookBead failed for agent=${row.assignee_agent_bead_id}`, - err, + err ); } } - beadOps.updateBeadStatus(this.sql, row.bead_id, "closed", "system"); + beadOps.updateBeadStatus(this.sql, row.bead_id, 'closed', 'system'); } // Close the convoy bead itself if not already auto-landed by // updateConvoyProgress (which fires when the last tracked bead closes). const current = this.getConvoy(convoyId); - if (current && current.status !== "landed") { + if (current && current.status !== 'landed') { query( this.sql, /* sql */ ` @@ -2438,7 +2245,7 @@ export class TownDO extends DurableObject { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, convoyId], + [timestamp, timestamp, convoyId] ); query( this.sql, @@ -2448,7 +2255,7 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [timestamp, convoyId], + [timestamp, convoyId] ); } @@ -2464,7 +2271,7 @@ export class TownDO extends DurableObject { rigId: string; convoyTitle: string; tasks: Array<{ title: string; body?: string; depends_on?: number[] }>; - merge_mode?: "review-then-land" | "review-and-merge"; + merge_mode?: 'review-then-land' | 'review-and-merge'; staged?: boolean; }): Promise<{ convoy: ConvoyEntry; @@ -2486,9 +2293,9 @@ export class TownDO extends DurableObject { const convoySlug = input.convoyTitle .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 40) || "convoy"; + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) || 'convoy'; const featureBranch = `convoy/${convoySlug}/${convoyId.slice(0, 8)}/head`; // 1. Validate the dependency graph has no cycles BEFORE persisting anything. @@ -2502,8 +2309,7 @@ export class TownDO extends DurableObject { } for (let i = 0; i < input.tasks.length; i++) { for (const depIdx of input.tasks[i].depends_on ?? []) { - if (depIdx < 0 || depIdx >= input.tasks.length || depIdx === i) - continue; + if (depIdx < 0 || depIdx >= input.tasks.length || depIdx === i) continue; (adj.get(depIdx) ?? []).push(i); inDegree.set(i, (inDegree.get(i) ?? 0) + 1); } @@ -2525,7 +2331,7 @@ export class TownDO extends DurableObject { } if (visited < input.tasks.length) { throw new Error( - `Convoy dependency graph contains a cycle — ${input.tasks.length - visited} tasks are involved in circular dependencies`, + `Convoy dependency graph contains a cycle — ${input.tasks.length - visited} tasks are involved in circular dependencies` ); } } @@ -2545,24 +2351,24 @@ export class TownDO extends DurableObject { `, [ convoyId, - "convoy", - "open", + 'convoy', + 'open', input.convoyTitle, null, // body null, // rig_id — intentionally null; a convoy is a town-level grouping that can span multiple rigs null, // parent_bead_id null, // assignee_agent_bead_id - "medium", - JSON.stringify(["gt:convoy"]), + 'medium', + JSON.stringify(['gt:convoy']), JSON.stringify({ feature_branch: featureBranch }), null, timestamp, timestamp, null, - ], + ] ); - const mergeMode = input.merge_mode ?? "review-then-land"; + const mergeMode = input.merge_mode ?? 'review-then-land'; const stagedValue = isStaged ? 1 : 0; @@ -2576,15 +2382,7 @@ export class TownDO extends DurableObject { ${convoy_metadata.columns.staged} ) VALUES (?, ?, ?, ?, ?, ?, ?) `, - [ - convoyId, - input.tasks.length, - 0, - null, - featureBranch, - mergeMode, - stagedValue, - ], + [convoyId, input.tasks.length, 0, null, featureBranch, mergeMode, stagedValue] ); // 2. Create all beads and track their IDs (needed for depends_on resolution) @@ -2593,10 +2391,10 @@ export class TownDO extends DurableObject { for (const task of input.tasks) { const createdBead = beadOps.createBead(this.sql, { - type: "issue", + type: 'issue', title: task.title, body: task.body, - priority: "medium", + priority: 'medium', rig_id: input.rigId, metadata: { convoy_id: convoyId, feature_branch: featureBranch }, }); @@ -2612,7 +2410,7 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [createdBead.bead_id, convoyId, "tracks"], + [createdBead.bead_id, convoyId, 'tracks'] ); } @@ -2631,7 +2429,7 @@ export class TownDO extends DurableObject { ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [beadIds[i], beadIds[depIdx], "blocks"], + [beadIds[i], beadIds[depIdx], 'blocks'] ); } } @@ -2639,10 +2437,10 @@ export class TownDO extends DurableObject { // Record bead_created events for reconciler (dual-write, no behavior change) for (let i = 0; i < beadIds.length; i++) { const hasBlockers = (input.tasks[i].depends_on ?? []).length > 0; - events.insertEvent(this.sql, "bead_created", { + events.insertEvent(this.sql, 'bead_created', { bead_id: beadIds[i], payload: { - bead_type: "issue", + bead_type: 'issue', rig_id: input.rigId, convoy_id: convoyId, has_blockers: hasBlockers, @@ -2665,9 +2463,9 @@ export class TownDO extends DurableObject { } const convoy = this.getConvoy(convoyId); - if (!convoy) throw new Error("Failed to create convoy"); + if (!convoy) throw new Error('Failed to create convoy'); this.emitEvent({ - event: "convoy.created", + event: 'convoy.created', townId: this.townId, convoyId, }); @@ -2695,14 +2493,14 @@ export class TownDO extends DurableObject { WHERE ${bead_dependencies.depends_on_bead_id} = ? AND ${bead_dependencies.dependency_type} = 'tracks' `, - [convoyId], + [convoyId] ), ]; const BeadIdRow = z.object({ bead_id: z.string() }); const trackedBeadIds = BeadIdRow.array() .parse(trackedRows) - .map((r) => r.bead_id); + .map(r => r.bead_id); const results: Array<{ bead: Bead; agent: Agent | null }> = []; @@ -2722,20 +2520,19 @@ export class TownDO extends DurableObject { SET ${convoy_metadata.columns.staged} = 0 WHERE ${convoy_metadata.bead_id} = ? `, - [convoyId], + [convoyId] ); - events.insertEvent(this.sql, "convoy_started", { + events.insertEvent(this.sql, 'convoy_started', { payload: { convoy_id: convoyId }, }); await this.armAlarmIfNeeded(); const updatedConvoy = this.getConvoy(convoyId); - if (!updatedConvoy) - throw new Error(`Failed to re-fetch convoy after start: ${convoyId}`); + if (!updatedConvoy) throw new Error(`Failed to re-fetch convoy after start: ${convoyId}`); this.emitEvent({ - event: "convoy.started", + event: 'convoy.started', townId: this.townId, convoyId, }); @@ -2752,10 +2549,10 @@ export class TownDO extends DurableObject { /* sql */ `${CONVOY_JOIN} WHERE ${beads.status} != 'closed' ORDER BY ${beads.created_at} DESC`, - [], + [] ), ]; - return rows.map((row) => toConvoy(ConvoyBeadRecord.parse(row))); + return rows.map(row => toConvoy(ConvoyBeadRecord.parse(row))); } /** @@ -2829,7 +2626,7 @@ export class TownDO extends DurableObject { AND ${bead_dependencies.dependency_type} = 'tracks' ORDER BY ${beads.created_at} ASC `, - [convoyId], + [convoyId] ), ]; @@ -2842,25 +2639,18 @@ export class TownDO extends DurableObject { }); // Get DAG edges (blocks dependencies) between tracked beads - const dependencyEdges = beadOps.getConvoyDependencyEdges( - this.sql, - convoyId, - ); + const dependencyEdges = beadOps.getConvoyDependencyEdges(this.sql, convoyId); return { ...convoy, - beads: trackedRows.map((row) => TrackedBeadRow.parse(row)), + beads: trackedRows.map(row => TrackedBeadRow.parse(row)), dependency_edges: dependencyEdges, }; } private getConvoy(convoyId: string): ConvoyEntry | null { const rows = [ - ...query( - this.sql, - /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, - [convoyId], - ), + ...query(this.sql, /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, [convoyId]), ]; if (rows.length === 0) return null; return toConvoy(ConvoyBeadRecord.parse(rows[0])); @@ -2870,9 +2660,7 @@ export class TownDO extends DurableObject { // Escalations (beads with type='escalation' + escalation_metadata) // ══════════════════════════════════════════════════════════════════ - async acknowledgeEscalation( - escalationId: string, - ): Promise { + async acknowledgeEscalation(escalationId: string): Promise { query( this.sql, /* sql */ ` @@ -2880,41 +2668,39 @@ export class TownDO extends DurableObject { SET ${escalation_metadata.columns.acknowledged} = 1, ${escalation_metadata.columns.acknowledged_at} = ? WHERE ${escalation_metadata.bead_id} = ? AND ${escalation_metadata.acknowledged} = 0 `, - [now(), escalationId], + [now(), escalationId] ); // Acknowledging an escalation also closes it — the mayor has seen // the issue and doesn't need it sitting open in the queue. // Guard with getBead so stale/duplicate acknowledge calls remain // idempotent instead of throwing on a missing bead. const escalationBead = beadOps.getBead(this.sql, escalationId); - if (escalationBead && escalationBead.status !== "closed") { - beadOps.updateBeadStatus(this.sql, escalationId, "closed", null); + if (escalationBead && escalationBead.status !== 'closed') { + beadOps.updateBeadStatus(this.sql, escalationId, 'closed', null); } this.emitEvent({ - event: "escalation.acknowledged", + event: 'escalation.acknowledged', townId: this.townId, beadId: escalationId, }); return this.getEscalation(escalationId); } - async listEscalations(filter?: { - acknowledged?: boolean; - }): Promise { + async listEscalations(filter?: { acknowledged?: boolean }): Promise { const rows = filter?.acknowledged !== undefined ? [ ...query( this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = ? ORDER BY ${beads.created_at} DESC LIMIT 100`, - [filter.acknowledged ? 1 : 0], + [filter.acknowledged ? 1 : 0] ), ] : [ ...query( this.sql, /* sql */ `${ESCALATION_JOIN} ORDER BY ${beads.created_at} DESC LIMIT 100`, - [], + [] ), ]; return EscalationBeadRecord.array().parse(rows).map(toEscalation); @@ -2924,7 +2710,7 @@ export class TownDO extends DurableObject { townId: string; source_rig_id: string; source_agent_id?: string; - severity: "low" | "medium" | "high" | "critical"; + severity: 'low' | 'medium' | 'high' | 'critical'; category?: string; message: string; }): Promise { @@ -2963,25 +2749,21 @@ export class TownDO extends DurableObject { `, [ beadId, - "escalation", - "open", + 'escalation', + 'open', `Escalation: ${input.message.slice(0, 100)}`, input.message, input.source_rig_id, null, null, - input.severity === "critical" - ? "critical" - : input.severity === "high" - ? "high" - : "medium", - JSON.stringify(["gt:escalation", `severity:${input.severity}`]), + input.severity === 'critical' ? 'critical' : input.severity === 'high' ? 'high' : 'medium', + JSON.stringify(['gt:escalation', `severity:${input.severity}`]), JSON.stringify(metadata), input.source_agent_id ?? null, timestamp, timestamp, null, - ], + ] ); // Create escalation_metadata @@ -2994,14 +2776,14 @@ export class TownDO extends DurableObject { ${escalation_metadata.columns.re_escalation_count}, ${escalation_metadata.columns.acknowledged_at} ) VALUES (?, ?, ?, ?, ?, ?) `, - [beadId, input.severity, input.category ?? null, 0, 0, null], + [beadId, input.severity, input.category ?? null, 0, 0, null] ); const escalation = this.getEscalation(beadId); - if (!escalation) throw new Error("Failed to create escalation"); + if (!escalation) throw new Error('Failed to create escalation'); this.emitEvent({ - event: "escalation.created", + event: 'escalation.created', townId: this.townId, rigId: input.source_rig_id, agentId: input.source_agent_id, @@ -3013,7 +2795,7 @@ export class TownDO extends DurableObject { // act on the escalation. Without this, escalation beads sit open // with no assignee and no automated follow-up. patrol.createTriageRequest(this.sql, { - triageType: "escalation", + triageType: 'escalation', agentBeadId: input.source_agent_id ?? null, title: `Escalation (${input.severity}): ${input.message.slice(0, 80)}`, context: { @@ -3025,28 +2807,25 @@ export class TownDO extends DurableObject { source_bead_id: sourceBeadId, }, options: - input.severity === "low" - ? ["NUDGE", "CLOSE_BEAD", "PROVIDE_GUIDANCE"] - : ["ESCALATE_TO_MAYOR", "RESTART", "CLOSE_BEAD", "REASSIGN_BEAD"], + input.severity === 'low' + ? ['NUDGE', 'CLOSE_BEAD', 'PROVIDE_GUIDANCE'] + : ['ESCALATE_TO_MAYOR', 'RESTART', 'CLOSE_BEAD', 'REASSIGN_BEAD'], rigId: input.source_rig_id, }); // Notify mayor directly for medium+ severity (in addition to triage) - if (input.severity !== "low") { + if (input.severity !== 'low') { this.sendMayorMessage( - `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}`, - ).catch((err) => { - console.warn( - `${TOWN_LOG} routeEscalation: failed to notify mayor:`, - err, - ); + `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}` + ).catch(err => { + console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); try { beadOps.logBeadEvent(this.sql, { beadId, agentId: input.source_agent_id ?? null, - eventType: "notification_failed", + eventType: 'notification_failed', metadata: { - target: "mayor", + target: 'mayor', reason: err instanceof Error ? err.message : String(err), severity: input.severity, }, @@ -3054,7 +2833,7 @@ export class TownDO extends DurableObject { } catch (logErr) { console.error( `${TOWN_LOG} routeEscalation: failed to log notification_failed event:`, - logErr, + logErr ); } }); @@ -3065,11 +2844,7 @@ export class TownDO extends DurableObject { private getEscalation(escalationId: string): EscalationEntry | null { const rows = [ - ...query( - this.sql, - /* sql */ `${ESCALATION_JOIN} WHERE ${beads.bead_id} = ?`, - [escalationId], - ), + ...query(this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${beads.bead_id} = ?`, [escalationId]), ]; if (rows.length === 0) return null; return toEscalation(EscalationBeadRecord.parse(rows[0])); @@ -3084,11 +2859,9 @@ export class TownDO extends DurableObject { // After destroy(), deleteAll() wipes storage but may not clear // the alarm (compat date < 2026-02-24). A resurrected alarm // will find no town:id — stop the loop immediately. - const storedId = await this.ctx.storage.get("town:id"); + const storedId = await this.ctx.storage.get('town:id'); if (!storedId) { - console.log( - `${TOWN_LOG} alarm: no town:id — town was destroyed, not re-arming`, - ); + console.log(`${TOWN_LOG} alarm: no town:id — town was destroyed, not re-arming`); await this.ctx.storage.deleteAlarm(); return; } @@ -3133,17 +2906,17 @@ export class TownDO extends DurableObject { FROM ${agent_metadata} WHERE ${agent_metadata.status} IN ('working', 'stalled') `, - [], + [] ), ]); if (workingAgentRows.length > 0) { - const statusChecks = workingAgentRows.map(async (row) => { + const statusChecks = workingAgentRows.map(async row => { try { const containerInfo = await dispatch.checkAgentContainerStatus( this.env, townId, - row.bead_id, + row.bead_id ); events.upsertContainerStatus(this.sql, row.bead_id, { status: containerInfo.status, @@ -3152,7 +2925,7 @@ export class TownDO extends DurableObject { } catch (err) { console.warn( `${TOWN_LOG} alarm: container status check failed for agent=${row.bead_id}`, - err, + err ); } }); @@ -3181,9 +2954,7 @@ export class TownDO extends DurableObject { const pending = events.drainEvents(this.sql); metrics.eventsDrained = pending.length; if (pending.length > 0) { - console.log( - `${TOWN_LOG} [reconciler] town=${townId} draining ${pending.length} event(s)`, - ); + console.log(`${TOWN_LOG} [reconciler] town=${townId} draining ${pending.length} event(s)`); } for (const event of pending) { try { @@ -3192,7 +2963,7 @@ export class TownDO extends DurableObject { } catch (err) { console.error( `${TOWN_LOG} [reconciler] town=${townId} applyEvent failed: event=${event.event_id} type=${event.event_type}`, - err, + err ); // Event stays unprocessed — will be retried on the next alarm tick. // Mark it processed anyway after 3 consecutive failures to prevent @@ -3201,10 +2972,7 @@ export class TownDO extends DurableObject { } } } catch (err) { - console.error( - `${TOWN_LOG} [reconciler] town=${townId} event drain failed`, - err, - ); + console.error(`${TOWN_LOG} [reconciler] town=${townId} event drain failed`, err); Sentry.captureException(err); } @@ -3214,12 +2982,11 @@ export class TownDO extends DurableObject { const actions = reconciler.reconcile(this.sql); metrics.actionsEmitted = actions.length; for (const a of actions) { - metrics.actionsByType[a.type] = - (metrics.actionsByType[a.type] ?? 0) + 1; + metrics.actionsByType[a.type] = (metrics.actionsByType[a.type] ?? 0) + 1; } if (actions.length > 0) { console.log( - `${TOWN_LOG} [reconciler] town=${townId} actions=${actions.length} types=${[...new Set(actions.map((a) => a.type))].join(",")}`, + `${TOWN_LOG} [reconciler] town=${townId} actions=${actions.length} types=${[...new Set(actions.map(a => a.type))].join(',')}` ); } const ctx = this.applyActionCtx; @@ -3230,24 +2997,21 @@ export class TownDO extends DurableObject { } catch (err) { console.error( `${TOWN_LOG} [reconciler] town=${townId} applyAction failed: type=${action.type}`, - err, + err ); } } } catch (err) { - console.error( - `${TOWN_LOG} [reconciler] town=${townId} reconcile failed`, - err, - ); + console.error(`${TOWN_LOG} [reconciler] town=${townId} reconcile failed`, err); Sentry.captureException(err); } // Phase 2: Execute side effects (async, best-effort) metrics.sideEffectsAttempted = sideEffects.length; if (sideEffects.length > 0) { - const results = await Promise.allSettled(sideEffects.map((fn) => fn())); + const results = await Promise.allSettled(sideEffects.map(fn => fn())); for (const r of results) { - if (r.status === "fulfilled") metrics.sideEffectsSucceeded++; + if (r.status === 'fulfilled') metrics.sideEffectsSucceeded++; else metrics.sideEffectsFailed++; } } @@ -3260,19 +3024,14 @@ export class TownDO extends DurableObject { // Emit as an analytics event for observability dashboards instead // of console.error (which spams Workers logs every 5s per town). this.emitEvent({ - event: "reconciler.invariant_violations", + event: 'reconciler.invariant_violations', townId, - label: violations - .map((v) => `[${v.invariant}] ${v.message}`) - .join("; "), + label: violations.map(v => `[${v.invariant}] ${v.message}`).join('; '), value: violations.length, }); } } catch (err) { - console.warn( - `${TOWN_LOG} [reconciler:invariants] town=${townId} check failed`, - err, - ); + console.warn(`${TOWN_LOG} [reconciler:invariants] town=${townId} check failed`, err); } metrics.wallClockMs = Date.now() - reconcilerStart; @@ -3281,17 +3040,17 @@ export class TownDO extends DurableObject { // ── Phase 3: Housekeeping (independent, all parallelizable) ──── await Promise.allSettled([ - this.deliverPendingMail().catch((err) => - console.warn(`${TOWN_LOG} alarm: deliverPendingMail failed`, err), + this.deliverPendingMail().catch(err => + console.warn(`${TOWN_LOG} alarm: deliverPendingMail failed`, err) ), - this.expireStaleNudges().catch((err) => - console.warn(`${TOWN_LOG} alarm: expireStaleNudges failed`, err), + this.expireStaleNudges().catch(err => + console.warn(`${TOWN_LOG} alarm: expireStaleNudges failed`, err) ), - this.reEscalateStaleEscalations().catch((err) => - console.warn(`${TOWN_LOG} alarm: reEscalation failed`, err), + this.reEscalateStaleEscalations().catch(err => + console.warn(`${TOWN_LOG} alarm: reEscalation failed`, err) ), - this.maybeDispatchTriageAgent().catch((err) => - console.warn(`${TOWN_LOG} alarm: maybeDispatchTriageAgent failed`, err), + this.maybeDispatchTriageAgent().catch(err => + console.warn(`${TOWN_LOG} alarm: maybeDispatchTriageAgent failed`, err) ), // Prune processed reconciler events older than 7 days Promise.resolve().then(() => { @@ -3326,8 +3085,7 @@ export class TownDO extends DurableObject { private async refreshContainerToken(): Promise { const TOKEN_REFRESH_INTERVAL_MS = 60 * 60_000; // 1 hour const now = Date.now(); - if (now - this.lastContainerTokenRefreshAt < TOKEN_REFRESH_INTERVAL_MS) - return; + if (now - this.lastContainerTokenRefreshAt < TOKEN_REFRESH_INTERVAL_MS) return; const townId = this.townId; if (!townId) return; @@ -3347,7 +3105,7 @@ export class TownDO extends DurableObject { private dispatchAgent( agent: Agent, bead: Bead, - options?: { systemPromptOverride?: string }, + options?: { systemPromptOverride?: string } ): Promise { return scheduling.dispatchAgent(this.schedulingCtx, agent, bead, options); } @@ -3373,11 +3131,9 @@ export class TownDO extends DurableObject { // rapid retry loops). Skip dispatch in either case. const triageBatchLike = patrol.TRIAGE_LABEL_LIKE.replace( patrol.TRIAGE_REQUEST_LABEL, - patrol.TRIAGE_BATCH_LABEL, + patrol.TRIAGE_BATCH_LABEL ); - const cooldownCutoff = new Date( - Date.now() - scheduling.DISPATCH_COOLDOWN_MS, - ).toISOString(); + const cooldownCutoff = new Date(Date.now() - scheduling.DISPATCH_COOLDOWN_MS).toISOString(); const existingBatch = [ ...query( this.sql, @@ -3392,12 +3148,12 @@ export class TownDO extends DurableObject { ) LIMIT 1 `, - [triageBatchLike, cooldownCutoff], + [triageBatchLike, cooldownCutoff] ), ]; if (existingBatch.length > 0) { console.log( - `${TOWN_LOG} maybeDispatchTriageAgent: triage batch bead active or in cooldown, skipping (${pendingCount} pending)`, + `${TOWN_LOG} maybeDispatchTriageAgent: triage batch bead active or in cooldown, skipping (${pendingCount} pending)` ); return; } @@ -3406,23 +3162,19 @@ export class TownDO extends DurableObject { // leaked phantom issue beads on early-return paths. const rigList = rigs.listRigs(this.sql); if (rigList.length === 0) { - console.warn( - `${TOWN_LOG} maybeDispatchTriageAgent: no rigs available, skipping`, - ); + console.warn(`${TOWN_LOG} maybeDispatchTriageAgent: no rigs available, skipping`); return; } const rigId = rigList[0].id; const rigConfig = await this.getRigConfig(rigId); if (!rigConfig) { - console.warn( - `${TOWN_LOG} maybeDispatchTriageAgent: no rig config for rig=${rigId}`, - ); + console.warn(`${TOWN_LOG} maybeDispatchTriageAgent: no rig config for rig=${rigId}`); return; } console.log( - `${TOWN_LOG} maybeDispatchTriageAgent: ${pendingCount} pending triage request(s), dispatching agent`, + `${TOWN_LOG} maybeDispatchTriageAgent: ${pendingCount} pending triage request(s), dispatching agent` ); const townConfig = await this.getTownConfig(); @@ -3430,71 +3182,54 @@ export class TownDO extends DurableObject { // Build the triage prompt from pending requests const pendingRequests = patrol.listPendingTriageRequests(this.sql); - const { buildTriageSystemPrompt } = - await import("../prompts/triage-system.prompt"); + const { buildTriageSystemPrompt } = await import('../prompts/triage-system.prompt'); const systemPrompt = buildTriageSystemPrompt(pendingRequests); // Only now create the synthetic bead — preconditions are verified. const triageBead = beadOps.createBead(this.sql, { - type: "issue", + type: 'issue', title: `Triage batch: ${pendingCount} request(s)`, - body: "Process all pending triage request beads and resolve each one.", - priority: "high", + body: 'Process all pending triage request beads and resolve each one.', + priority: 'high', labels: [patrol.TRIAGE_BATCH_LABEL], - created_by: "patrol", + created_by: 'patrol', }); - const triageAgent = agents.getOrCreateAgent( - this.sql, - "polecat", - rigId, - this.townId, - ); + const triageAgent = agents.getOrCreateAgent(this.sql, 'polecat', rigId, this.townId); agents.hookBead(this.sql, triageAgent.id, triageBead.bead_id); - const started = await dispatch.startAgentInContainer( - this.env, - this.ctx.storage, - { - townId: this.townId, - rigId, - userId: rigConfig.userId, - agentId: triageAgent.id, - agentName: triageAgent.name, - role: "polecat", - identity: triageAgent.identity, - beadId: triageBead.bead_id, - beadTitle: triageBead.title, - beadBody: triageBead.body ?? "", - checkpoint: null, - gitUrl: rigConfig.gitUrl, - defaultBranch: rigConfig.defaultBranch, - kilocodeToken, - townConfig, - systemPromptOverride: systemPrompt, - platformIntegrationId: rigConfig.platformIntegrationId, - lightweight: true, - }, - ); + const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { + townId: this.townId, + rigId, + userId: rigConfig.userId, + agentId: triageAgent.id, + agentName: triageAgent.name, + role: 'polecat', + identity: triageAgent.identity, + beadId: triageBead.bead_id, + beadTitle: triageBead.title, + beadBody: triageBead.body ?? '', + checkpoint: null, + gitUrl: rigConfig.gitUrl, + defaultBranch: rigConfig.defaultBranch, + kilocodeToken, + townConfig, + systemPromptOverride: systemPrompt, + platformIntegrationId: rigConfig.platformIntegrationId, + lightweight: true, + }); if (started) { // Mark the agent as working so the duplicate-guard on the next // alarm tick sees it and skips dispatch. - agents.updateAgentStatus(this.sql, triageAgent.id, "working"); + agents.updateAgentStatus(this.sql, triageAgent.id, 'working'); } else { agents.unhookBead(this.sql, triageAgent.id); // Failing the batch bead triggers cooldown: the guard at the top of // this method skips dispatch while a failed batch bead's updated_at // is within DISPATCH_COOLDOWN_MS. - beadOps.updateBeadStatus( - this.sql, - triageBead.bead_id, - "failed", - triageAgent.id, - ); - console.error( - `${TOWN_LOG} maybeDispatchTriageAgent: triage agent failed to start`, - ); + beadOps.updateBeadStatus(this.sql, triageBead.bead_id, 'failed', triageAgent.id); + console.error(`${TOWN_LOG} maybeDispatchTriageAgent: triage agent failed to start`); } } @@ -3510,50 +3245,41 @@ export class TownDO extends DurableObject { if (pendingByAgent.size === 0) return; console.log( - `${TOWN_LOG} deliverPendingMail: ${pendingByAgent.size} agent(s) with pending mail`, + `${TOWN_LOG} deliverPendingMail: ${pendingByAgent.size} agent(s) with pending mail` ); - const deliveries = [...pendingByAgent.entries()].map( - async ([agentId, messages]) => { - const lines = messages.map( - (m) => `[MAIL from ${m.from_agent_id}] ${m.subject}\n${m.body}`, - ); - const prompt = `You have ${messages.length} new mail message(s):\n\n${lines.join("\n\n---\n\n")}`; + const deliveries = [...pendingByAgent.entries()].map(async ([agentId, messages]) => { + const lines = messages.map(m => `[MAIL from ${m.from_agent_id}] ${m.subject}\n${m.body}`); + const prompt = `You have ${messages.length} new mail message(s):\n\n${lines.join('\n\n---\n\n')}`; - const sent = await dispatch.sendMessageToAgent( - this.env, - this.townId, - agentId, - prompt, - ); + const sent = await dispatch.sendMessageToAgent(this.env, this.townId, agentId, prompt); - if (sent) { - // Mark delivered only after the container accepted the message - mail.readAndDeliverMail(this.sql, agentId); - if ( - messages.some( - (m) => - m.subject === "TRIAGE_NUDGE" || - m.subject === "GUPP_ESCALATION" || - m.subject === "GUPP_CHECK", - ) - ) { - this.emitEvent({ - event: "nudge.delivered", - townId: this.townId, - agentId, - }); - } - console.log( - `${TOWN_LOG} deliverPendingMail: delivered ${messages.length} message(s) to agent=${agentId}`, - ); - } else { - console.warn( - `${TOWN_LOG} deliverPendingMail: failed to push mail to agent=${agentId}, will retry next tick`, - ); + if (sent) { + // Mark delivered only after the container accepted the message + mail.readAndDeliverMail(this.sql, agentId); + if ( + messages.some( + m => + m.subject === 'TRIAGE_NUDGE' || + m.subject === 'GUPP_ESCALATION' || + m.subject === 'GUPP_CHECK' + ) + ) { + this.emitEvent({ + event: 'nudge.delivered', + townId: this.townId, + agentId, + }); } - }, - ); + console.log( + `${TOWN_LOG} deliverPendingMail: delivered ${messages.length} message(s) to agent=${agentId}` + ); + } else { + console.warn( + `${TOWN_LOG} deliverPendingMail: failed to push mail to agent=${agentId}, will retry next tick` + ); + } + }); await Promise.allSettled(deliveries); } @@ -3564,19 +3290,15 @@ export class TownDO extends DurableObject { */ private async checkPRStatus( prUrl: string, - townConfig: TownConfig, - ): Promise<"open" | "merged" | "closed" | null> { + townConfig: TownConfig + ): Promise<'open' | 'merged' | 'closed' | null> { // GitHub PR URL format: https://github.com/{owner}/{repo}/pull/{number} - const ghMatch = prUrl.match( - /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/, - ); + const ghMatch = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); if (ghMatch) { const [, owner, repo, numberStr] = ghMatch; const token = townConfig.git_auth.github_token; if (!token) { - console.warn( - `${TOWN_LOG} checkPRStatus: no github_token configured, cannot poll ${prUrl}`, - ); + console.warn(`${TOWN_LOG} checkPRStatus: no github_token configured, cannot poll ${prUrl}`); return null; } @@ -3585,14 +3307,14 @@ export class TownDO extends DurableObject { { headers: { Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "Gastown-Refinery/1.0", + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Gastown-Refinery/1.0', }, - }, + } ); if (!response.ok) { console.warn( - `${TOWN_LOG} checkPRStatus: GitHub API returned ${response.status} for ${prUrl}`, + `${TOWN_LOG} checkPRStatus: GitHub API returned ${response.status} for ${prUrl}` ); return null; } @@ -3602,22 +3324,18 @@ export class TownDO extends DurableObject { const data = GitHubPRStatusSchema.safeParse(json); if (!data.success) return null; - if (data.data.merged) return "merged"; - if (data.data.state === "closed") return "closed"; - return "open"; + if (data.data.merged) return 'merged'; + if (data.data.state === 'closed') return 'closed'; + return 'open'; } // GitLab MR URL format: https://{host}/{path}/-/merge_requests/{iid} - const glMatch = prUrl.match( - /^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/, - ); + const glMatch = prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); if (glMatch) { const [, instanceUrl, projectPath, iidStr] = glMatch; const token = townConfig.git_auth.gitlab_token; if (!token) { - console.warn( - `${TOWN_LOG} checkPRStatus: no gitlab_token configured, cannot poll ${prUrl}`, - ); + console.warn(`${TOWN_LOG} checkPRStatus: no gitlab_token configured, cannot poll ${prUrl}`); return null; } @@ -3627,9 +3345,9 @@ export class TownDO extends DurableObject { const configuredHost = townConfig.git_auth.gitlab_instance_url ? new URL(townConfig.git_auth.gitlab_instance_url).hostname : null; - if (prHost !== "gitlab.com" && prHost !== configuredHost) { + if (prHost !== 'gitlab.com' && prHost !== configuredHost) { console.warn( - `${TOWN_LOG} checkPRStatus: refusing to send gitlab_token to unknown host: ${prHost}`, + `${TOWN_LOG} checkPRStatus: refusing to send gitlab_token to unknown host: ${prHost}` ); return null; } @@ -3638,12 +3356,12 @@ export class TownDO extends DurableObject { const response = await fetch( `${instanceUrl}/api/v4/projects/${encodedPath}/merge_requests/${iidStr}`, { - headers: { "PRIVATE-TOKEN": token }, - }, + headers: { 'PRIVATE-TOKEN': token }, + } ); if (!response.ok) { console.warn( - `${TOWN_LOG} checkPRStatus: GitLab API returned ${response.status} for ${prUrl}`, + `${TOWN_LOG} checkPRStatus: GitLab API returned ${response.status} for ${prUrl}` ); return null; } @@ -3653,14 +3371,12 @@ export class TownDO extends DurableObject { const data = GitLabMRStatusSchema.safeParse(glJson); if (!data.success) return null; - if (data.data.state === "merged") return "merged"; - if (data.data.state === "closed") return "closed"; - return "open"; + if (data.data.state === 'merged') return 'merged'; + if (data.data.state === 'closed') return 'closed'; + return 'open'; } - console.warn( - `${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`, - ); + console.warn(`${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`); return null; } @@ -3672,15 +3388,14 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = 0 AND ${escalation_metadata.re_escalation_count} < ?`, - [MAX_RE_ESCALATIONS], + [MAX_RE_ESCALATIONS] ), - ].map((r) => toEscalation(EscalationBeadRecord.parse(r))); + ].map(r => toEscalation(EscalationBeadRecord.parse(r))); const nowMs = Date.now(); for (const esc of candidates) { const ageMs = nowMs - new Date(esc.created_at).getTime(); - const requiredAgeMs = - (esc.re_escalation_count + 1) * STALE_ESCALATION_THRESHOLD_MS; + const requiredAgeMs = (esc.re_escalation_count + 1) * STALE_ESCALATION_THRESHOLD_MS; if (ageMs < requiredAgeMs) continue; const currentIdx = SEVERITY_ORDER.indexOf(esc.severity); @@ -3695,24 +3410,21 @@ export class TownDO extends DurableObject { ${escalation_metadata.columns.re_escalation_count} = ${escalation_metadata.columns.re_escalation_count} + 1 WHERE ${escalation_metadata.bead_id} = ? `, - [newSeverity, esc.id], + [newSeverity, esc.id] ); - if (newSeverity !== "low") { + if (newSeverity !== 'low') { this.sendMayorMessage( - `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}`, - ).catch((err) => { - console.warn( - `${TOWN_LOG} re-escalation: failed to notify mayor:`, - err, - ); + `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}` + ).catch(err => { + console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); try { beadOps.logBeadEvent(this.sql, { beadId: esc.id, agentId: null, - eventType: "notification_failed", + eventType: 'notification_failed', metadata: { - target: "mayor", + target: 'mayor', reason: err instanceof Error ? err.message : String(err), severity: newSeverity, re_escalation: true, @@ -3721,7 +3433,7 @@ export class TownDO extends DurableObject { } catch (logErr) { console.error( `${TOWN_LOG} re-escalation: failed to log notification_failed event:`, - logErr, + logErr ); } }); @@ -3749,7 +3461,7 @@ export class TownDO extends DurableObject { try { const container = getTownContainerStub(this.env, townId); - await container.fetch("http://container/health", { + await container.fetch('http://container/health', { signal: AbortSignal.timeout(5_000), }); } catch { @@ -3762,7 +3474,7 @@ export class TownDO extends DurableObject { private async armAlarmIfNeeded(): Promise { // Don't resurrect the alarm on a destroyed DO. After destroy(), // town:id is wiped — if it's missing, the town was deleted. - const storedId = await this.ctx.storage.get("town:id"); + const storedId = await this.ctx.storage.get('town:id'); if (!storedId) return; const current = await this.ctx.storage.getAlarm(); @@ -3794,9 +3506,7 @@ export class TownDO extends DurableObject { // Re-arm if missing — this is the whole point of the watchdog if (!alarmSet) { - console.warn( - `${TOWN_LOG} healthCheck: alarm not set for town=${townId}, re-arming`, - ); + console.warn(`${TOWN_LOG} healthCheck: alarm not set for town=${townId}, re-arming`); await this.ctx.storage.setAlarm(Date.now() + ACTIVE_ALARM_INTERVAL_MS); } @@ -3805,9 +3515,9 @@ export class TownDO extends DurableObject { ...query( this.sql, /* sql */ `SELECT COUNT(*) AS cnt FROM ${agent_metadata} WHERE ${agent_metadata.status} IN ('working', 'stalled')`, - [], + [] ), - ][0]?.cnt ?? 0, + ][0]?.cnt ?? 0 ); const pendingBeads = Number( @@ -3815,9 +3525,9 @@ export class TownDO extends DurableObject { ...query( this.sql, /* 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, + ][0]?.cnt ?? 0 ); return { townId, alarmSet, activeAgents, pendingBeads }; @@ -3862,9 +3572,7 @@ export class TownDO extends DurableObject { }> { const currentAlarm = await this.ctx.storage.getAlarm(); const active = this.hasActiveWork(); - const intervalMs = active - ? ACTIVE_ALARM_INTERVAL_MS - : IDLE_ALARM_INTERVAL_MS; + const intervalMs = active ? ACTIVE_ALARM_INTERVAL_MS : IDLE_ALARM_INTERVAL_MS; // Agent counts by status const agentRows = [ @@ -3875,7 +3583,7 @@ export class TownDO extends DurableObject { FROM ${agent_metadata} GROUP BY ${agent_metadata.status} `, - [], + [] ), ]; const agentCounts = { working: 0, idle: 0, stalled: 0, dead: 0, total: 0 }; @@ -3896,7 +3604,7 @@ export class TownDO extends DurableObject { WHERE ${beads.type} NOT IN ('agent', 'message') GROUP BY ${beads.status} `, - [], + [] ), ]; const beadCounts = { @@ -3909,10 +3617,10 @@ export class TownDO extends DurableObject { 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; + 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; } // Triage request count (issue beads with gt:triage-request label) @@ -3929,9 +3637,9 @@ export class TownDO extends DurableObject { AND ${beads.title} = 'GUPP_CHECK' AND ${beads.status} = 'open' `, - [], + [] ), - ][0]?.cnt ?? 0, + ][0]?.cnt ?? 0 ); const guppEscalations = Number( @@ -3944,9 +3652,9 @@ export class TownDO extends DurableObject { AND ${beads.title} = 'GUPP_ESCALATION' AND ${beads.status} = 'open' `, - [], + [] ), - ][0]?.cnt ?? 0, + ][0]?.cnt ?? 0 ); const stalledAgents = agentCounts.stalled; @@ -3968,9 +3676,9 @@ export class TownDO extends DurableObject { OR ${agent_metadata.last_activity_at} < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes') ) `, - [], + [] ), - ][0]?.cnt ?? 0, + ][0]?.cnt ?? 0 ); // Recent bead events (last 20) for the activity feed @@ -3985,11 +3693,11 @@ export class TownDO extends DurableObject { ORDER BY be.created_at DESC LIMIT 20 `, - [], + [] ), ]; - const recentEvents = recentRows.map((row) => ({ + const recentEvents = recentRows.map(row => ({ time: `${row.created_at as string}`, type: `${row.event_type as string}`, message: formatEventMessage(row), @@ -3997,11 +3705,9 @@ export class TownDO extends DurableObject { return { alarm: { - nextFireAt: currentAlarm - ? new Date(Number(currentAlarm)).toISOString() - : null, + nextFireAt: currentAlarm ? new Date(Number(currentAlarm)).toISOString() : null, intervalMs, - intervalLabel: active ? "active (5s)" : "idle (60s)", + intervalLabel: active ? 'active (5s)' : 'idle (60s)', }, agents: agentCounts, beads: beadCounts, @@ -4033,7 +3739,7 @@ export class TownDO extends DurableObject { AND ${beads.type} != 'agent' ORDER BY ${beads.type}, ${beads.status} `, - [], + [] ), ]; } @@ -4052,7 +3758,7 @@ export class TownDO extends DurableObject { ${agent_metadata.last_activity_at} FROM ${agent_metadata} `, - [], + [] ), ]; } @@ -4064,7 +3770,7 @@ export class TownDO extends DurableObject { try { const allAgents = agents.listAgents(this.sql); await Promise.allSettled( - allAgents.map((agent) => getAgentDOStub(this.env, agent.id).destroy()), + allAgents.map(agent => getAgentDOStub(this.env, agent.id).destroy()) ); } catch (err) { console.warn(`${TOWN_LOG} destroy: agent cleanup failed`, err); diff --git a/cloudflare-gastown/src/dos/town/reconciler.ts b/cloudflare-gastown/src/dos/town/reconciler.ts index 38ec480da..0e9ff7b0e 100644 --- a/cloudflare-gastown/src/dos/town/reconciler.ts +++ b/cloudflare-gastown/src/dos/town/reconciler.ts @@ -11,39 +11,30 @@ * See reconciliation-spec.md §5.3. */ -import { z } from "zod"; -import { beads, BeadRecord } from "../../db/tables/beads.table"; -import { - agent_metadata, - AgentMetadataRecord, -} from "../../db/tables/agent-metadata.table"; -import { - review_metadata, - ReviewMetadataRecord, -} from "../../db/tables/review-metadata.table"; -import { - convoy_metadata, - ConvoyMetadataRecord, -} from "../../db/tables/convoy-metadata.table"; -import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; -import { agent_nudges } from "../../db/tables/agent-nudges.table"; -import { query } from "../../util/query.util"; +import { z } from 'zod'; +import { beads, BeadRecord } from '../../db/tables/beads.table'; +import { agent_metadata, AgentMetadataRecord } from '../../db/tables/agent-metadata.table'; +import { review_metadata, ReviewMetadataRecord } from '../../db/tables/review-metadata.table'; +import { convoy_metadata, ConvoyMetadataRecord } from '../../db/tables/convoy-metadata.table'; +import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; +import { agent_nudges } from '../../db/tables/agent-nudges.table'; +import { query } from '../../util/query.util'; import { GUPP_WARN_MS, GUPP_ESCALATE_MS, GUPP_FORCE_STOP_MS, AGENT_GC_RETENTION_MS, TRIAGE_LABEL_LIKE, -} from "./patrol"; -import { DISPATCH_COOLDOWN_MS, MAX_DISPATCH_ATTEMPTS } from "./scheduling"; -import * as reviewQueue from "./review-queue"; -import * as agents from "./agents"; -import * as beadOps from "./beads"; -import { getRig } from "./rigs"; -import type { Action } from "./actions"; -import type { TownEventRecord } from "../../db/tables/town-events.table"; +} from './patrol'; +import { DISPATCH_COOLDOWN_MS, MAX_DISPATCH_ATTEMPTS } from './scheduling'; +import * as reviewQueue from './review-queue'; +import * as agents from './agents'; +import * as beadOps from './beads'; +import { getRig } from './rigs'; +import type { Action } from './actions'; +import type { TownEventRecord } from '../../db/tables/town-events.table'; -const LOG = "[reconciler]"; +const LOG = '[reconciler]'; // ── Timeouts (from spec §7) ───────────────────────────────────────── @@ -146,76 +137,71 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { const payload = event.payload; switch (event.event_type) { - case "agent_done": { + case 'agent_done': { if (!event.agent_id) { console.warn(`${LOG} applyEvent: agent_done missing agent_id`); return; } - const branch = typeof payload.branch === "string" ? payload.branch : ""; - const pr_url = - typeof payload.pr_url === "string" ? payload.pr_url : undefined; - const summary = - typeof payload.summary === "string" ? payload.summary : undefined; + const branch = typeof payload.branch === 'string' ? payload.branch : ''; + const pr_url = typeof payload.pr_url === 'string' ? payload.pr_url : undefined; + const summary = typeof payload.summary === 'string' ? payload.summary : undefined; reviewQueue.agentDone(sql, event.agent_id, { branch, pr_url, summary }); return; } - case "agent_completed": { + case 'agent_completed': { if (!event.agent_id) { console.warn(`${LOG} applyEvent: agent_completed missing agent_id`); return; } const status = - payload.status === "completed" || payload.status === "failed" - ? payload.status - : "failed"; - const reason = - typeof payload.reason === "string" ? payload.reason : undefined; + payload.status === 'completed' || payload.status === 'failed' ? payload.status : 'failed'; + const reason = typeof payload.reason === 'string' ? payload.reason : undefined; reviewQueue.agentCompleted(sql, event.agent_id, { status, reason }); return; } - case "pr_status_changed": { + case 'pr_status_changed': { if (!event.bead_id) { console.warn(`${LOG} applyEvent: pr_status_changed missing bead_id`); return; } const pr_state = payload.pr_state; - if (pr_state === "merged") { + if (pr_state === 'merged') { reviewQueue.completeReviewWithResult(sql, { entry_id: event.bead_id, - status: "merged", - message: "PR merged (detected by polling)", + status: 'merged', + message: 'PR merged (detected by polling)', }); - } else if (pr_state === "closed") { + } else if (pr_state === 'closed') { reviewQueue.completeReviewWithResult(sql, { entry_id: event.bead_id, - status: "failed", - message: "PR closed without merge", + status: 'failed', + message: 'PR closed without merge', }); } return; } - case "bead_created": { + case 'bead_created': { // No state change needed — bead already exists in DB. // Reconciler will pick it up as unassigned on next pass. return; } - case "bead_cancelled": { + case 'bead_cancelled': { if (!event.bead_id) { console.warn(`${LOG} applyEvent: bead_cancelled missing bead_id`); return; } const cancelStatus = - payload.cancel_status === "closed" || payload.cancel_status === "failed" + payload.cancel_status === 'closed' || payload.cancel_status === 'failed' ? payload.cancel_status - : "failed"; + : 'failed'; - beadOps.updateBeadStatus(sql, event.bead_id, cancelStatus, "system"); + beadOps.updateBeadStatus(sql, event.bead_id, cancelStatus, 'system'); // Unhook any agent hooked to this bead const hookedAgentRows = z @@ -229,7 +215,7 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { FROM ${agent_metadata} WHERE ${agent_metadata.current_hook_bead_id} = ? `, - [event.bead_id], + [event.bead_id] ), ]); for (const row of hookedAgentRows) { @@ -238,9 +224,8 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { return; } - case "convoy_started": { - const convoyId = - typeof payload.convoy_id === "string" ? payload.convoy_id : null; + case 'convoy_started': { + const convoyId = typeof payload.convoy_id === 'string' ? payload.convoy_id : null; if (!convoyId) { console.warn(`${LOG} applyEvent: convoy_started missing convoy_id`); return; @@ -252,12 +237,12 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { SET ${convoy_metadata.columns.staged} = 0 WHERE ${convoy_metadata.columns.bead_id} = ? `, - [convoyId], + [convoyId] ); return; } - case "container_status": { + case 'container_status': { if (!event.agent_id) return; const containerStatus = payload.status as string; @@ -271,52 +256,49 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { // The 3-minute grace period covers the 60s HTTP timeout plus // typical cold start time (git clone + worktree). Truly dead // agents are caught by reconcileAgents after 90s of no heartbeats. - if (containerStatus === "not_found" && agent.last_activity_at) { - const ageSec = - (Date.now() - new Date(agent.last_activity_at).getTime()) / 1000; + if (containerStatus === 'not_found' && agent.last_activity_at) { + const ageSec = (Date.now() - new Date(agent.last_activity_at).getTime()) / 1000; if (ageSec < 180) return; // 3-minute grace for cold starts } if ( - (agent.status === "working" || agent.status === "stalled") && - (containerStatus === "exited" || containerStatus === "not_found") + (agent.status === 'working' || agent.status === 'stalled') && + (containerStatus === 'exited' || containerStatus === 'not_found') ) { - if (agent.role === "refinery") { + if (agent.role === 'refinery') { // Check if gt_done already completed the MR if (agent.current_hook_bead_id) { const mr = beadOps.getBead(sql, agent.current_hook_bead_id); - if (mr && (mr.status === "closed" || mr.status === "failed")) { + if (mr && (mr.status === 'closed' || mr.status === 'failed')) { // MR already terminal — clean up the refinery agents.unhookBead(sql, event.agent_id); - agents.updateAgentStatus(sql, event.agent_id, "idle"); + agents.updateAgentStatus(sql, event.agent_id, 'idle'); agents.writeCheckpoint(sql, event.agent_id, null); } else { // Refinery died without completing — set idle, keep hook. // reconcileReviewQueue Rule 6 will retry dispatch. - agents.updateAgentStatus(sql, event.agent_id, "idle"); + agents.updateAgentStatus(sql, event.agent_id, 'idle'); } } else { - agents.updateAgentStatus(sql, event.agent_id, "idle"); + agents.updateAgentStatus(sql, event.agent_id, 'idle'); } } else { // Non-refinery died — set idle. Bead stays in_progress. // reconcileBeads Rule 3 will reset it to open after 5 min. - agents.updateAgentStatus(sql, event.agent_id, "idle"); + agents.updateAgentStatus(sql, event.agent_id, 'idle'); } } return; } - case "nudge_timeout": { + case 'nudge_timeout': { // GUPP violations are handled by reconcileGUPP on the next pass. // The event just records the fact for audit trail. return; } default: { - console.warn( - `${LOG} applyEvent: unknown event type: ${event.event_type}`, - ); + console.warn(`${LOG} applyEvent: unknown event type: ${event.event_type}`); } } } @@ -364,30 +346,30 @@ export function reconcileAgents(sql: SqlStorage): Action[] { LEFT JOIN ${beads} b ON b.${beads.columns.bead_id} = ${agent_metadata.bead_id} WHERE ${agent_metadata.status} = 'working' `, - [], + [] ), ]); for (const agent of workingAgents) { // Mayors are always working with no hook — skip them - if (agent.role === "mayor") continue; + if (agent.role === 'mayor') continue; if (!agent.last_activity_at) { // No heartbeat ever received — container may have failed to start actions.push({ - type: "transition_agent", + type: 'transition_agent', agent_id: agent.bead_id, - from: "working", - to: "idle", - reason: "no heartbeat received since dispatch", + from: 'working', + to: 'idle', + reason: 'no heartbeat received since dispatch', }); } else if (staleMs(agent.last_activity_at, 90_000)) { actions.push({ - type: "transition_agent", + type: 'transition_agent', agent_id: agent.bead_id, - from: "working", - to: "idle", - reason: "heartbeat lost (3 missed cycles)", + from: 'working', + to: 'idle', + reason: 'heartbeat lost (3 missed cycles)', }); } else if (!agent.current_hook_bead_id) { // Agent is working with fresh heartbeat but no hook — it's running @@ -395,11 +377,11 @@ export function reconcileAgents(sql: SqlStorage): Action[] { // or the hook was cleared by another code path). Set to idle so // processReviewQueue / schedulePendingWork can use it. actions.push({ - type: "transition_agent", + type: 'transition_agent', agent_id: agent.bead_id, - from: "working", - to: "idle", - reason: "working agent has no hook (gt_done already completed)", + from: 'working', + to: 'idle', + reason: 'working agent has no hook (gt_done already completed)', }); } } @@ -419,7 +401,7 @@ export function reconcileAgents(sql: SqlStorage): Action[] { WHERE ${agent_metadata.status} = 'idle' AND ${agent_metadata.current_hook_bead_id} IS NOT NULL `, - [], + [] ), ]); @@ -437,33 +419,33 @@ export function reconcileAgents(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [agent.current_hook_bead_id], + [agent.current_hook_bead_id] ), ]); if (hookedRows.length === 0) { // Hooked bead doesn't exist — stale reference actions.push({ - type: "unhook_agent", + type: 'unhook_agent', agent_id: agent.bead_id, - reason: "hooked bead does not exist", + reason: 'hooked bead does not exist', }); actions.push({ - type: "clear_agent_checkpoint", + type: 'clear_agent_checkpoint', agent_id: agent.bead_id, }); continue; } const hookedStatus = hookedRows[0].status; - if (hookedStatus === "closed" || hookedStatus === "failed") { + if (hookedStatus === 'closed' || hookedStatus === 'failed') { actions.push({ - type: "unhook_agent", + type: 'unhook_agent', agent_id: agent.bead_id, - reason: "hooked bead is terminal", + reason: 'hooked bead is terminal', }); actions.push({ - type: "clear_agent_checkpoint", + type: 'clear_agent_checkpoint', agent_id: agent.bead_id, }); } @@ -511,7 +493,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND cm.${convoy_metadata.columns.staged} = 1 ) `, - [TRIAGE_LABEL_LIKE], + [TRIAGE_LABEL_LIKE] ), ]); @@ -521,8 +503,8 @@ export function reconcileBeads(sql: SqlStorage): Action[] { // that a hook_agent + dispatch_agent is needed. // The action includes rig_id so Phase 3's applyAction can resolve the agent. actions.push({ - type: "dispatch_agent", - agent_id: "", // resolved at apply time + type: 'dispatch_agent', + agent_id: '', // resolved at apply time bead_id: bead.bead_id, rig_id: bead.rig_id, }); @@ -544,7 +526,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND ${agent_metadata.current_hook_bead_id} IS NOT NULL AND ${agent_metadata.columns.role} != 'refinery' `, - [], + [] ), ]); @@ -557,17 +539,17 @@ export function reconcileBeads(sql: SqlStorage): Action[] { // Check max dispatch attempts if (agent.dispatch_attempts >= MAX_DISPATCH_ATTEMPTS) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: agent.current_hook_bead_id, from: null, - to: "failed", - reason: "max dispatch attempts exceeded", - actor: "system", + to: 'failed', + reason: 'max dispatch attempts exceeded', + actor: 'system', }); actions.push({ - type: "unhook_agent", + type: 'unhook_agent', agent_id: agent.bead_id, - reason: "max dispatch attempts", + reason: 'max dispatch attempts', }); continue; } @@ -584,13 +566,13 @@ export function reconcileBeads(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [agent.current_hook_bead_id], + [agent.current_hook_bead_id] ), ]); if (hookedRows.length === 0) continue; const hooked = hookedRows[0]; - if (hooked.status !== "open") continue; + if (hooked.status !== 'open') continue; // Check blockers const blockerCount = z @@ -607,17 +589,17 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' AND blocker.${beads.columns.status} NOT IN ('closed', 'failed') `, - [agent.current_hook_bead_id], + [agent.current_hook_bead_id] ), ]); if (blockerCount[0]?.cnt > 0) continue; actions.push({ - type: "dispatch_agent", + type: 'dispatch_agent', agent_id: agent.bead_id, bead_id: agent.current_hook_bead_id, - rig_id: hooked.rig_id ?? agent.rig_id ?? "", + rig_id: hooked.rig_id ?? agent.rig_id ?? '', }); } @@ -636,7 +618,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'issue' AND b.${beads.columns.status} = 'in_progress' `, - [], + [] ), ]); @@ -662,22 +644,22 @@ export function reconcileBeads(sql: SqlStorage): Action[] { OR ${agent_metadata.last_activity_at} > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 seconds') ) `, - [bead.bead_id], + [bead.bead_id] ), ]); if (hookedAgent.length > 0) continue; actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: bead.bead_id, - from: "in_progress", - to: "open", - reason: "agent lost", - actor: "system", + from: 'in_progress', + to: 'open', + reason: 'agent lost', + actor: 'system', }); actions.push({ - type: "clear_bead_assignee", + type: 'clear_bead_assignee', bead_id: bead.bead_id, }); } @@ -697,7 +679,7 @@ export function reconcileBeads(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'issue' AND b.${beads.columns.status} = 'in_review' `, - [], + [] ), ]); @@ -719,38 +701,36 @@ export function reconcileBeads(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND mr.${beads.columns.type} = 'merge_request' `, - [bead.bead_id], + [bead.bead_id] ), ]); if (mrBeads.length === 0) continue; - const allTerminal = mrBeads.every( - (mr) => mr.status === "closed" || mr.status === "failed", - ); + const allTerminal = mrBeads.every(mr => mr.status === 'closed' || mr.status === 'failed'); if (!allTerminal) continue; - const anyMerged = mrBeads.some((mr) => mr.status === "closed"); + const anyMerged = mrBeads.some(mr => mr.status === 'closed'); if (anyMerged) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: bead.bead_id, - from: "in_review", - to: "closed", - reason: "MR merged (reconciler safety net)", - actor: "system", + from: 'in_review', + to: 'closed', + reason: 'MR merged (reconciler safety net)', + actor: 'system', }); } else { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: bead.bead_id, - from: "in_review", - to: "open", - reason: "all reviews failed", - actor: "system", + from: 'in_review', + to: 'open', + reason: 'all reviews failed', + actor: 'system', }); actions.push({ - type: "clear_bead_assignee", + type: 'clear_bead_assignee', bead_id: bead.bead_id, }); } @@ -781,15 +761,15 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'merge_request' AND b.${beads.columns.status} IN ('open', 'in_progress') `, - [], + [] ), ]); for (const mr of mrBeads) { // Rule 1: PR-strategy MR beads in_progress need polling - if (mr.status === "in_progress" && mr.pr_url) { + if (mr.status === 'in_progress' && mr.pr_url) { actions.push({ - type: "poll_pr", + type: 'poll_pr', bead_id: mr.bead_id, pr_url: mr.pr_url, }); @@ -799,7 +779,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Skip MR beads with unresolved rework blockers — they're waiting for // a polecat to finish rework, which is a normal in-flight state. if ( - mr.status === "in_progress" && + mr.status === 'in_progress' && !mr.pr_url && staleMs(mr.updated_at, STUCK_REVIEW_TIMEOUT_MS) ) { @@ -807,20 +787,20 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { const workingAgent = hasWorkingAgentHooked(sql, mr.bead_id); if (!workingAgent) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: mr.bead_id, - from: "in_progress", - to: "open", - reason: "stuck review, no working agent", - actor: "system", + from: 'in_progress', + to: 'open', + reason: 'stuck review, no working agent', + actor: 'system', }); // Unhook any idle agent still pointing at this MR const idleAgent = getIdleAgentHookedTo(sql, mr.bead_id); if (idleAgent) { actions.push({ - type: "unhook_agent", + type: 'unhook_agent', agent_id: idleAgent, - reason: "stuck review cleanup", + reason: 'stuck review cleanup', }); } } @@ -829,7 +809,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Rule 3: Abandoned MR beads in_progress, no PR, no agent hooked, stale >2min // Skip MR beads with rework blockers (same reasoning as Rule 2). if ( - mr.status === "in_progress" && + mr.status === 'in_progress' && !mr.pr_url && staleMs(mr.updated_at, ABANDONED_MR_TIMEOUT_MS) ) { @@ -837,12 +817,12 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { const anyAgent = hasAnyAgentHooked(sql, mr.bead_id); if (!anyAgent) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: mr.bead_id, - from: "in_progress", - to: "open", - reason: "abandoned, no agent hooked", - actor: "system", + from: 'in_progress', + to: 'open', + reason: 'abandoned, no agent hooked', + actor: 'system', }); } } @@ -850,19 +830,19 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Rule 4: PR-strategy MR beads orphaned (refinery dispatched then died, stale >30min) // Only in_progress — open beads are just waiting for the refinery to pop them. if ( - mr.status === "in_progress" && + mr.status === 'in_progress' && mr.pr_url && staleMs(mr.updated_at, ORPHANED_PR_REVIEW_TIMEOUT_MS) ) { const workingAgent = hasWorkingAgentHooked(sql, mr.bead_id); if (!workingAgent) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: mr.bead_id, from: mr.status, - to: "failed", - reason: "PR review orphaned", - actor: "system", + to: 'failed', + reason: 'PR review orphaned', + actor: 'system', }); } } @@ -883,7 +863,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.status} = 'open' AND b.${beads.columns.rig_id} IS NOT NULL `, - [], + [] ), ]); @@ -907,7 +887,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.rig_id} = ? AND rm.${review_metadata.columns.pr_url} IS NULL `, - [rig_id], + [rig_id] ), ]); if ((inProgressCount[0]?.cnt ?? 0) > 0) continue; @@ -928,7 +908,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND b.${beads.columns.rig_id} = ? LIMIT 1 `, - [rig_id], + [rig_id] ), ]); @@ -948,7 +928,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { ORDER BY ${beads.columns.created_at} ASC LIMIT 1 `, - [rig_id], + [rig_id] ), ]); @@ -958,16 +938,16 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // agent_id — applyAction will create the refinery via getOrCreateAgent. if (refinery.length === 0) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: oldestMr[0].bead_id, - from: "open", - to: "in_progress", - reason: "popped for review (creating refinery)", - actor: "system", + from: 'open', + to: 'in_progress', + reason: 'popped for review (creating refinery)', + actor: 'system', }); actions.push({ - type: "dispatch_agent", - agent_id: "", + type: 'dispatch_agent', + agent_id: '', bead_id: oldestMr[0].bead_id, rig_id, }); @@ -975,23 +955,23 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { } const ref = refinery[0]; - if (ref.status !== "idle" || ref.current_hook_bead_id) continue; + if (ref.status !== 'idle' || ref.current_hook_bead_id) continue; actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: oldestMr[0].bead_id, - from: "open", - to: "in_progress", - reason: "popped for review", - actor: "system", + from: 'open', + to: 'in_progress', + reason: 'popped for review', + actor: 'system', }); actions.push({ - type: "hook_agent", + type: 'hook_agent', agent_id: ref.bead_id, bead_id: oldestMr[0].bead_id, }); actions.push({ - type: "dispatch_agent", + type: 'dispatch_agent', agent_id: ref.bead_id, bead_id: oldestMr[0].bead_id, rig_id, @@ -1014,7 +994,7 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { AND ${agent_metadata.status} = 'idle' AND ${agent_metadata.current_hook_bead_id} IS NOT NULL `, - [], + [] ), ]); @@ -1027,17 +1007,17 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { // Circuit-breaker: fail the MR bead after too many attempts (#1342) if (ref.dispatch_attempts >= MAX_DISPATCH_ATTEMPTS) { actions.push({ - type: "transition_bead", + type: 'transition_bead', bead_id: ref.current_hook_bead_id, from: null, - to: "failed", - reason: "refinery max dispatch attempts exceeded", - actor: "system", + to: 'failed', + reason: 'refinery max dispatch attempts exceeded', + actor: 'system', }); actions.push({ - type: "unhook_agent", + type: 'unhook_agent', agent_id: ref.bead_id, - reason: "max dispatch attempts", + reason: 'max dispatch attempts', }); continue; } @@ -1057,21 +1037,21 @@ export function reconcileReviewQueue(sql: SqlStorage): Action[] { FROM ${beads} WHERE ${beads.bead_id} = ? `, - [ref.current_hook_bead_id], + [ref.current_hook_bead_id] ), ]); if (mrRows.length === 0) continue; const mr = mrRows[0]; - if (mr.type !== "merge_request" || mr.status !== "in_progress") continue; + if (mr.type !== 'merge_request' || mr.status !== 'in_progress') continue; // Container status is checked at apply time (async). In shadow mode, // we just note that a dispatch is needed. actions.push({ - type: "dispatch_agent", + type: 'dispatch_agent', agent_id: ref.bead_id, bead_id: ref.current_hook_bead_id, - rig_id: mr.rig_id ?? ref.rig_id ?? "", + rig_id: mr.rig_id ?? ref.rig_id ?? '', }); } @@ -1101,7 +1081,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { WHERE b.${beads.columns.type} = 'convoy' AND b.${beads.columns.status} = 'open' `, - [], + [] ), ]); @@ -1123,7 +1103,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND tracked.${beads.columns.type} = 'issue' `, - [convoy.bead_id], + [convoy.bead_id] ), ]); @@ -1133,7 +1113,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { // Update progress if stale if (closed_count !== convoy.closed_beads) { actions.push({ - type: "update_convoy_progress", + type: 'update_convoy_progress', convoy_id: convoy.bead_id, closed_beads: closed_count, }); @@ -1159,7 +1139,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND mr.${beads.columns.type} = 'merge_request' AND mr.${beads.columns.status} IN ('open', 'in_progress') `, - [convoy.bead_id], + [convoy.bead_id] ), ]); @@ -1174,10 +1154,10 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { /* ignore */ } - if (convoy.merge_mode === "review-then-land" && convoy.feature_branch) { + if (convoy.merge_mode === 'review-then-land' && convoy.feature_branch) { if (!parsedMeta.ready_to_land) { actions.push({ - type: "set_convoy_ready_to_land", + type: 'set_convoy_ready_to_land', convoy_id: convoy.bead_id, }); } @@ -1198,17 +1178,15 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND bd.${bead_dependencies.columns.dependency_type} = 'tracks' AND mr.${beads.columns.type} = 'merge_request' `, - [convoy.bead_id], + [convoy.bead_id] ), ]); // If a landing MR was already merged (closed), close the convoy - const hasMergedLanding = landingMrs.some( - (mr) => mr.status === "closed", - ); + const hasMergedLanding = landingMrs.some(mr => mr.status === 'closed'); if (hasMergedLanding) { actions.push({ - type: "close_convoy", + type: 'close_convoy', convoy_id: convoy.bead_id, }); continue; @@ -1216,7 +1194,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { // If a landing MR is active (open or in_progress), wait for it const hasActiveLanding = landingMrs.some( - (mr) => mr.status === "open" || mr.status === "in_progress", + mr => mr.status === 'open' || mr.status === 'in_progress' ); if (hasActiveLanding) continue; @@ -1238,18 +1216,18 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { AND tracked.${beads.columns.rig_id} IS NOT NULL LIMIT 1 `, - [convoy.bead_id], + [convoy.bead_id] ), ]); if (rigRows.length > 0) { const rig = getRig(sql, rigRows[0].rig_id); actions.push({ - type: "create_landing_mr", + type: 'create_landing_mr', convoy_id: convoy.bead_id, rig_id: rigRows[0].rig_id, feature_branch: convoy.feature_branch, - target_branch: rig?.default_branch ?? "main", + target_branch: rig?.default_branch ?? 'main', }); } } @@ -1257,7 +1235,7 @@ export function reconcileConvoys(sql: SqlStorage): Action[] { } else { // review-and-merge or no feature branch — auto-close actions.push({ - type: "close_convoy", + type: 'close_convoy', convoy_id: convoy.bead_id, }); } @@ -1290,7 +1268,7 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { LEFT JOIN ${beads} b ON b.${beads.columns.bead_id} = ${agent_metadata.bead_id} WHERE ${agent_metadata.status} IN ('working', 'stalled') `, - [], + [] ), ]); @@ -1306,37 +1284,37 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { if (elapsed > GUPP_FORCE_STOP_MS) { actions.push({ - type: "transition_agent", + type: 'transition_agent', agent_id: agent.bead_id, from: agent.status, - to: "stalled", - reason: "GUPP force stop — no SDK activity for 2h", + to: 'stalled', + reason: 'GUPP force stop — no SDK activity for 2h', }); actions.push({ - type: "stop_agent", + type: 'stop_agent', agent_id: agent.bead_id, - reason: "exceeded 2h GUPP limit", + reason: 'exceeded 2h GUPP limit', }); actions.push({ - type: "create_triage_request", + type: 'create_triage_request', agent_id: agent.bead_id, - triage_type: "stuck_agent", - reason: "GUPP force stop", + triage_type: 'stuck_agent', + reason: 'GUPP force stop', }); } else if (elapsed > GUPP_ESCALATE_MS) { - if (!hasRecentNudge(sql, agent.bead_id, "escalate")) { + if (!hasRecentNudge(sql, agent.bead_id, 'escalate')) { actions.push({ - type: "send_nudge", + type: 'send_nudge', agent_id: agent.bead_id, message: - "You have been working for over 1 hour without completing your task. Please wrap up or report if you are stuck.", - tier: "escalate", + 'You have been working for over 1 hour without completing your task. Please wrap up or report if you are stuck.', + tier: 'escalate', }); actions.push({ - type: "create_triage_request", + type: 'create_triage_request', agent_id: agent.bead_id, - triage_type: "stuck_agent", - reason: "GUPP escalation", + triage_type: 'stuck_agent', + reason: 'GUPP escalation', }); } } else if (elapsed > 15 * 60_000) { @@ -1344,18 +1322,18 @@ export function reconcileGUPP(sql: SqlStorage): Action[] { // Skip if agent is mid-tool-call — long-running tools like git clone are normal. let tools: string[] = []; try { - tools = JSON.parse(agent.active_tools ?? "[]") as string[]; + tools = JSON.parse(agent.active_tools ?? '[]') as string[]; } catch { /* ignore */ } - if (tools.length === 0 && !hasRecentNudge(sql, agent.bead_id, "warn")) { + if (tools.length === 0 && !hasRecentNudge(sql, agent.bead_id, 'warn')) { actions.push({ - type: "send_nudge", + type: 'send_nudge', agent_id: agent.bead_id, message: - "You have been idle for 15 minutes with no tool activity. Please check your progress.", - tier: "warn", + 'You have been idle for 15 minutes with no tool activity. Please check your progress.', + tier: 'warn', }); } } @@ -1386,16 +1364,16 @@ export function reconcileGC(sql: SqlStorage): Action[] { AND ${agent_metadata.columns.role} IN ('polecat', 'refinery') AND ${agent_metadata.current_hook_bead_id} IS NULL `, - [], + [] ), ]); for (const agent of gcCandidates) { if (staleMs(agent.last_activity_at, AGENT_GC_RETENTION_MS)) { actions.push({ - type: "delete_agent", + type: 'delete_agent', agent_id: agent.bead_id, - reason: "GC: idle > 24h", + reason: 'GC: idle > 24h', }); } } @@ -1406,10 +1384,7 @@ export function reconcileGC(sql: SqlStorage): Action[] { // ── Helpers ───────────────────────────────────────────────────────── /** Check if an MR bead has open rework beads blocking it. */ -function hasUnresolvedReworkBlockers( - sql: SqlStorage, - mrBeadId: string, -): boolean { +function hasUnresolvedReworkBlockers(sql: SqlStorage, mrBeadId: string): boolean { const rows = [ ...query( sql, @@ -1421,7 +1396,7 @@ function hasUnresolvedReworkBlockers( AND rework.${beads.columns.status} NOT IN ('closed', 'failed') LIMIT 1 `, - [mrBeadId], + [mrBeadId] ), ]; return rows.length > 0; @@ -1437,7 +1412,7 @@ function hasWorkingAgentHooked(sql: SqlStorage, beadId: string): boolean { AND ${agent_metadata.status} IN ('working', 'stalled') LIMIT 1 `, - [beadId], + [beadId] ), ]; return rows.length > 0; @@ -1452,7 +1427,7 @@ function hasAnyAgentHooked(sql: SqlStorage, beadId: string): boolean { WHERE ${agent_metadata.current_hook_bead_id} = ? LIMIT 1 `, - [beadId], + [beadId] ), ]; return rows.length > 0; @@ -1472,17 +1447,13 @@ function getIdleAgentHookedTo(sql: SqlStorage, beadId: string): string | null { AND ${agent_metadata.status} = 'idle' LIMIT 1 `, - [beadId], + [beadId] ), ]); return rows.length > 0 ? rows[0].bead_id : null; } -function hasRecentNudge( - sql: SqlStorage, - agentId: string, - tier: string, -): boolean { +function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean { // Check if a nudge with this exact tier source was created in the last 60 min. // The source is set to `reconciler:${tier}` by applyAction('send_nudge'). const cutoff = new Date(Date.now() - 60 * 60_000).toISOString(); @@ -1496,7 +1467,7 @@ function hasRecentNudge( AND ${agent_nudges.created_at} > ? LIMIT 1 `, - [agentId, `reconciler:${tier}`, cutoff], + [agentId, `reconciler:${tier}`, cutoff] ), ]; return rows.length > 0; @@ -1533,7 +1504,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { WHERE ${agent_metadata.status} = 'working' AND ${agent_metadata.current_hook_bead_id} IS NULL `, - [], + [] ), ]); for (const a of unhookedWorkers) { @@ -1556,7 +1527,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { WHERE ${beads.type} = 'convoy' AND ${beads.status} = 'in_progress' `, - [], + [] ), ]); for (const c of inProgressConvoys) { @@ -1582,7 +1553,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { GROUP BY ${beads.rig_id} HAVING count(*) > 1 `, - [], + [] ), ]); for (const r of duplicateMrPerRig) { @@ -1606,7 +1577,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { GROUP BY ${agent_metadata.current_hook_bead_id} HAVING count(*) > 1 `, - [], + [] ), ]); for (const m of multiHooked) { @@ -1638,7 +1609,7 @@ export function checkInvariants(sql: SqlStorage): Violation[] { AND mr.${beads.columns.status} IN ('open', 'in_progress') ) `, - [], + [] ), ]); for (const b of orphanedInReview) { diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index a516cd1fe..de600bf2b 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -6,17 +6,13 @@ * - Molecules are parent beads with type='molecule' + child step beads */ -import { z } from "zod"; -import { - beads, - BeadRecord, - MergeRequestBeadRecord, -} from "../../db/tables/beads.table"; -import { review_metadata } from "../../db/tables/review-metadata.table"; -import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; -import { agent_metadata } from "../../db/tables/agent-metadata.table"; -import { convoy_metadata } from "../../db/tables/convoy-metadata.table"; -import { query } from "../../util/query.util"; +import { z } from 'zod'; +import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads.table'; +import { review_metadata } from '../../db/tables/review-metadata.table'; +import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; +import { agent_metadata } from '../../db/tables/agent-metadata.table'; +import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; +import { query } from '../../util/query.util'; import { logBeadEvent, getBead, @@ -27,15 +23,10 @@ import { getConvoyForBead, getConvoyFeatureBranch, getConvoyMergeMode, -} from "./beads"; -import { getAgent, unhookBead, updateAgentStatus } from "./agents"; -import { getRig } from "./rigs"; -import type { - ReviewQueueInput, - ReviewQueueEntry, - AgentDoneInput, - Molecule, -} from "../../types"; +} from './beads'; +import { getAgent, unhookBead, updateAgentStatus } from './agents'; +import { getRig } from './rigs'; +import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; // Review entries stuck in 'running' past this timeout are reset to 'pending'. // Only applies when no agent (working or idle) is hooked to the MR bead. @@ -74,34 +65,29 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { // The polecat that submitted the review — stored in metadata (not assignee, // which is set to the refinery when it claims the MR bead via hookBead). agent_id: - typeof row.metadata?.source_agent_id === "string" + typeof row.metadata?.source_agent_id === 'string' ? row.metadata.source_agent_id - : (row.created_by ?? ""), + : (row.created_by ?? ''), bead_id: - typeof row.metadata?.source_bead_id === "string" - ? row.metadata.source_bead_id - : row.bead_id, - rig_id: row.rig_id ?? "", + typeof row.metadata?.source_bead_id === 'string' ? row.metadata.source_bead_id : row.bead_id, + rig_id: row.rig_id ?? '', branch: row.branch, pr_url: row.pr_url, status: - row.status === "open" - ? "pending" - : row.status === "in_progress" - ? "running" - : row.status === "closed" - ? "merged" - : "failed", + row.status === 'open' + ? 'pending' + : row.status === 'in_progress' + ? 'running' + : row.status === 'closed' + ? 'merged' + : 'failed', summary: row.body, created_at: row.created_at, processed_at: row.updated_at === row.created_at ? null : row.updated_at, }; } -export function submitToReviewQueue( - sql: SqlStorage, - input: ReviewQueueInput, -): void { +export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { const id = generateId(); const timestamp = now(); @@ -121,14 +107,12 @@ export function submitToReviewQueue( // - For standalone beads → rig's default branch // We pass defaultBranch from the caller so we don't hardcode 'main'. const convoyId = getConvoyForBead(sql, input.bead_id); - const convoyFeatureBranch = convoyId - ? getConvoyFeatureBranch(sql, convoyId) - : null; + const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; const targetBranch = - convoyMergeMode === "review-then-land" && convoyFeatureBranch + convoyMergeMode === 'review-then-land' && convoyFeatureBranch ? convoyFeatureBranch - : (input.default_branch ?? "main"); + : (input.default_branch ?? 'main'); if (convoyId) { metadata.convoy_id = convoyId; @@ -152,21 +136,21 @@ export function submitToReviewQueue( `, [ id, - "merge_request", - "open", + 'merge_request', + 'open', `Review: ${input.branch}`, input.summary ?? null, input.rig_id, null, null, // assignee left null — refinery claims it via hookBead - "medium", - JSON.stringify(["gt:merge-request"]), + 'medium', + JSON.stringify(['gt:merge-request']), JSON.stringify(metadata), input.agent_id, // created_by records who submitted timestamp, timestamp, null, - ], + ] ); // Link MR bead → source bead via bead_dependencies so the DAG is queryable @@ -179,7 +163,7 @@ export function submitToReviewQueue( ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, 'tracks') `, - [id, input.bead_id], + [id, input.bead_id] ); // Create the review_metadata satellite @@ -192,13 +176,13 @@ export function submitToReviewQueue( ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.branch, targetBranch, null, input.pr_url ?? null, 0], + [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] ); logBeadEvent(sql, { beadId: input.bead_id, agentId: input.agent_id, - eventType: "review_submitted", + eventType: 'review_submitted', newValue: input.branch, metadata: { branch: input.branch, target_branch: targetBranch }, }); @@ -228,7 +212,7 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ORDER BY ${beads.created_at} ASC LIMIT 1 `, - [], + [] ), ]; @@ -245,28 +229,28 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), entry.id], + [now(), entry.id] ); - return { ...entry, status: "running", processed_at: now() }; + return { ...entry, status: 'running', processed_at: now() }; } export function completeReview( sql: SqlStorage, entryId: string, - status: "merged" | "failed", + status: 'merged' | 'failed' ): void { // Guard: don't overwrite terminal states (closed MR bead that was // already merged should never be set to 'failed' by a stale call) const current = getBead(sql, entryId); - if (current && (current.status === "closed" || current.status === "failed")) { + if (current && (current.status === 'closed' || current.status === 'failed')) { console.warn( - `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping`, + `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping` ); return; } - const beadStatus = status === "merged" ? "closed" : "failed"; + const beadStatus = status === 'merged' ? 'closed' : 'failed'; const timestamp = now(); query( sql, @@ -277,12 +261,7 @@ export function completeReview( ${beads.columns.closed_at} = ? WHERE ${beads.bead_id} = ? `, - [ - beadStatus, - timestamp, - beadStatus === "closed" ? timestamp : null, - entryId, - ], + [beadStatus, timestamp, beadStatus === 'closed' ? timestamp : null, entryId] ); } @@ -293,20 +272,18 @@ export function completeReviewWithResult( sql: SqlStorage, input: { entry_id: string; - status: "merged" | "failed" | "conflict"; + status: 'merged' | 'failed' | 'conflict'; message?: string; commit_sha?: string; - }, + } ): void { // On conflict, mark the review entry as failed and create an escalation bead - const resolvedStatus = input.status === "conflict" ? "failed" : input.status; + const resolvedStatus = input.status === 'conflict' ? 'failed' : input.status; completeReview(sql, input.entry_id, resolvedStatus); // Find the review entry to get agent IDs const entryRows = [ - ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [ - input.entry_id, - ]), + ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [input.entry_id]), ]; if (entryRows.length === 0) return; const parsed = MergeRequestBeadRecord.parse(entryRows[0]); @@ -315,7 +292,7 @@ export function completeReviewWithResult( logBeadEvent(sql, { beadId: entry.bead_id, agentId: entry.agent_id, - eventType: "review_completed", + eventType: 'review_completed', newValue: input.status, metadata: { message: input.message, @@ -323,12 +300,12 @@ export function completeReviewWithResult( }, }); - if (input.status === "merged") { + if (input.status === 'merged') { const mergeTimestamp = now(); console.log( `[review-queue] completeReviewWithResult MERGED: entry_id=${input.entry_id} ` + `entry.bead_id (source)=${entry.bead_id} entry.id (MR)=${entry.id} — ` + - `calling closeBead on source`, + `calling closeBead on source` ); closeBead(sql, entry.bead_id, entry.agent_id); @@ -354,7 +331,7 @@ export function completeReviewWithResult( AND dep.${bead_dependencies.columns.dependency_type} = 'tracks' ) `, - [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id], + [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id] ); // closeBead → updateBeadStatus short-circuits when completeReview already @@ -365,7 +342,7 @@ export function completeReviewWithResult( // If this was a convoy landing MR, also set landed_at on the convoy metadata const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead?.type === "convoy") { + if (sourceBead?.type === 'convoy') { query( sql, /* sql */ ` @@ -373,16 +350,16 @@ export function completeReviewWithResult( SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [now(), entry.bead_id], + [now(), entry.bead_id] ); } - } else if (input.status === "conflict") { + } else if (input.status === 'conflict') { // Create an escalation bead so the conflict is visible and actionable createBead(sql, { - type: "escalation", + type: 'escalation', title: `Merge conflict: ${input.message ?? entry.branch}`, body: input.message, - priority: "high", + priority: 'high', metadata: { source_bead_id: entry.bead_id, source_agent_id: entry.agent_id, @@ -395,10 +372,10 @@ export function completeReviewWithResult( const conflictSourceBead = getBead(sql, entry.bead_id); if ( conflictSourceBead && - conflictSourceBead.status !== "closed" && - conflictSourceBead.status !== "failed" + conflictSourceBead.status !== 'closed' && + conflictSourceBead.status !== 'failed' ) { - updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); + updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); query( sql, /* sql */ ` @@ -406,10 +383,10 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id], + [entry.bead_id] ); } - } else if (input.status === "failed") { + } else if (input.status === 'failed') { // Review failed (rework requested): return source bead to open so // the normal scheduling path (feedStrandedConvoys → hookBead → // schedulePendingWork → dispatch) handles rework. Clear the stale @@ -417,12 +394,8 @@ export function completeReviewWithResult( // This avoids the fire-and-forget rework dispatch race in TownDO // where the dispatch fails and rehookOrphanedBeads churn. const sourceBead = getBead(sql, entry.bead_id); - if ( - sourceBead && - sourceBead.status !== "closed" && - sourceBead.status !== "failed" - ) { - updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); + if (sourceBead && sourceBead.status !== 'closed' && sourceBead.status !== 'failed') { + updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); query( sql, /* sql */ ` @@ -430,7 +403,7 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id], + [entry.bead_id] ); } } @@ -445,7 +418,7 @@ export function completeReviewWithResult( /** Get review_metadata for an MR bead. */ export function getReviewMetadata( sql: SqlStorage, - mrBeadId: string, + mrBeadId: string ): { branch: string; target_branch: string; pr_url: string | null } | null { const rows = z .object({ @@ -464,23 +437,17 @@ export function getReviewMetadata( FROM ${review_metadata} WHERE ${review_metadata.bead_id} = ? `, - [mrBeadId], + [mrBeadId] ), ]); return rows[0] ?? null; } -export function setReviewPrUrl( - sql: SqlStorage, - entryId: string, - prUrl: string, -): boolean { +export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): boolean { // Reject non-HTTPS URLs to prevent storing garbage from LLM output. // Invalid URLs would cause pollPendingPRs to poll indefinitely. - if (!prUrl.startsWith("https://")) { - console.warn( - `[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`, - ); + if (!prUrl.startsWith('https://')) { + console.warn(`[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`); return false; } query( @@ -490,7 +457,7 @@ export function setReviewPrUrl( SET ${review_metadata.columns.pr_url} = ? WHERE ${review_metadata.bead_id} = ? `, - [prUrl, entryId], + [prUrl, entryId] ); // Also write to bead metadata so the PR URL is visible in the standard bead list @@ -501,7 +468,7 @@ export function setReviewPrUrl( SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.pr_url', ?) WHERE ${beads.bead_id} = ? `, - [prUrl, entryId], + [prUrl, entryId] ); return true; } @@ -519,17 +486,13 @@ export function markReviewInReview(sql: SqlStorage, entryId: string): void { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [new Date().toISOString(), entryId], + [new Date().toISOString(), entryId] ); } // ── Agent Done ────────────────────────────────────────────────────── -export function agentDone( - sql: SqlStorage, - agentId: string, - input: AgentDoneInput, -): void { +export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { const agent = getAgent(sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) { @@ -541,7 +504,7 @@ export function agentDone( // but the hook was cleared by zombie detection. We MUST still complete // the review — otherwise the source bead stays open forever. Find the // most recent non-closed MR bead assigned to this agent and complete it. - if (agent.role === "refinery") { + if (agent.role === 'refinery') { const recentMrRows = [ ...query( sql, @@ -554,15 +517,13 @@ export function agentDone( ORDER BY ${beads.updated_at} DESC LIMIT 1 `, - [agentId], + [agentId] ), ]; if (recentMrRows.length > 0) { - const mrBeadId = z - .object({ bead_id: z.string() }) - .parse(recentMrRows[0]).bead_id; + const mrBeadId = z.object({ bead_id: z.string() }).parse(recentMrRows[0]).bead_id; console.log( - `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}`, + `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}` ); if (input.pr_url) { const stored = setReviewPrUrl(sql, mrBeadId, input.pr_url); @@ -571,17 +532,15 @@ export function agentDone( } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "failed", + status: 'failed', message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); } } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "merged", - message: - input.summary ?? - "Merged by refinery agent (recovered from unhook)", + status: 'merged', + message: input.summary ?? 'Merged by refinery agent (recovered from unhook)', }); } return; @@ -589,7 +548,7 @@ export function agentDone( } console.warn( - `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring`, + `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring` ); return; } @@ -599,10 +558,7 @@ export function agentDone( // beads (created_by = 'patrol'). User-created beads that happen to carry // the gt:triage label go through normal review flow. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if ( - hookedBead?.labels.includes("gt:triage") && - hookedBead.created_by === "patrol" - ) { + if (hookedBead?.labels.includes('gt:triage') && hookedBead.created_by === 'patrol') { closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; @@ -612,16 +568,16 @@ export function agentDone( // to an existing branch (the one the refinery already reviewed). Closing // the rework bead unblocks the MR bead, and the reconciler re-dispatches // the refinery to re-review. - if (hookedBead?.labels.includes("gt:rework")) { + if (hookedBead?.labels.includes('gt:rework')) { console.log( - `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)`, + `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)` ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; } - if (agent.role === "refinery") { + if (agent.role === 'refinery') { // The refinery handles merging (direct strategy) or PR creation (pr strategy) // itself. When it calls gt_done: // - With pr_url: refinery created a PR → store URL, mark as in_review, poll it @@ -637,30 +593,30 @@ export function agentDone( logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: "pr_created", + eventType: 'pr_created', newValue: input.pr_url, - metadata: { pr_url: input.pr_url, created_by: "refinery" }, + metadata: { pr_url: input.pr_url, created_by: 'refinery' }, }); } else { // Invalid URL — fail the review so it doesn't poll forever completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "failed", + status: 'failed', message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: "pr_creation_failed", - metadata: { pr_url: input.pr_url, reason: "invalid_url" }, + eventType: 'pr_creation_failed', + metadata: { pr_url: input.pr_url, reason: 'invalid_url' }, }); } } else { // Direct strategy: refinery already merged and pushed completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "merged", - message: input.summary ?? "Merged by refinery agent", + status: 'merged', + message: input.summary ?? 'Merged by refinery agent', }); } @@ -669,7 +625,7 @@ export function agentDone( // refinery is available for new work. Without this, processReviewQueue // sees the refinery as 'working' and won't pop the next MR bead until // agentCompleted fires (when the container process eventually exits). - updateAgentStatus(sql, agentId, "idle"); + updateAgentStatus(sql, agentId, 'idle'); return; } @@ -677,13 +633,13 @@ export function agentDone( if (!agent.rig_id) { console.warn( - `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue`, + `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue` ); } // Resolve the rig's default branch so submitToReviewQueue can use it // instead of hardcoding 'main' for standalone/review-and-merge beads. - const rigId = agent.rig_id ?? ""; + const rigId = agent.rig_id ?? ''; const rig = rigId ? getRig(sql, rigId) : null; submitToReviewQueue(sql, { @@ -702,7 +658,7 @@ export function agentDone( // worked on it. It will be closed (or returned to in_progress) by the // refinery after review. unhookBead(sql, agentId); - updateBeadStatus(sql, sourceBead, "in_review", agentId); + updateBeadStatus(sql, sourceBead, 'in_review', agentId); } /** @@ -725,14 +681,14 @@ export type AgentCompletedResult = { export function agentCompleted( sql: SqlStorage, agentId: string, - input: { status: "completed" | "failed"; reason?: string }, + input: { status: 'completed' | 'failed'; reason?: string } ): AgentCompletedResult { const result: AgentCompletedResult = { reworkSourceBeadId: null }; const agent = getAgent(sql, agentId); if (!agent) return result; if (agent.current_hook_bead_id) { - if (agent.role === "refinery") { + if (agent.role === 'refinery') { // NEVER fail or unhook a refinery from agentCompleted. // agentCompleted races with gt_done: the process exits, the // container sends /completed, but gt_done's HTTP request may @@ -753,16 +709,16 @@ export function agentCompleted( // before calling gt_done. Don't close the bead — just unhook. The reconciler's // Rule 3 will reset it to open after the staleness timeout. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (input.status === "failed") { - updateBeadStatus(sql, agent.current_hook_bead_id, "failed", agentId); - } else if (hookedBead && hookedBead.status === "in_progress") { + if (input.status === 'failed') { + updateBeadStatus(sql, agent.current_hook_bead_id, 'failed', agentId); + } else if (hookedBead && hookedBead.status === 'in_progress') { // Agent exited 'completed' but bead is still in_progress — gt_done was never called. // Don't close the bead. Rule 3 will handle rework. console.log( `[review-queue] agentCompleted: polecat ${agentId} exited without gt_done — ` + - `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)`, + `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)` ); - } else if (hookedBead && hookedBead.status === "open") { + } else if (hookedBead && hookedBead.status === 'open') { // Bead is open (wasn't dispatched yet or was already reset). No-op. } else { // Bead is in_review, closed, or failed — gt_done already ran. No-op on bead. @@ -787,7 +743,7 @@ export function agentCompleted( END WHERE ${agent_metadata.bead_id} = ? `, - [agentId], + [agentId] ); return result; @@ -799,11 +755,7 @@ export function agentCompleted( * Create a molecule: a parent bead with type='molecule', child step beads * linked via parent_bead_id, and step ordering via bead_dependencies. */ -export function createMolecule( - sql: SqlStorage, - beadId: string, - formula: unknown, -): Molecule { +export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown): Molecule { const id = generateId(); const timestamp = now(); const formulaArr = Array.isArray(formula) ? formula : []; @@ -823,21 +775,21 @@ export function createMolecule( `, [ id, - "molecule", - "open", + 'molecule', + 'open', `Molecule for bead ${beadId}`, null, null, null, null, - "medium", - JSON.stringify(["gt:molecule"]), + 'medium', + JSON.stringify(['gt:molecule']), JSON.stringify({ source_bead_id: beadId, formula }), null, timestamp, timestamp, null, - ], + ] ); // Create child step beads and dependency chain @@ -860,22 +812,21 @@ export function createMolecule( `, [ stepId, - "issue", - "open", - z.object({ title: z.string() }).safeParse(step).data?.title ?? - `Step ${i + 1}`, - typeof step === "string" ? step : JSON.stringify(step), + 'issue', + 'open', + z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, + typeof step === 'string' ? step : JSON.stringify(step), null, id, null, - "medium", + 'medium', JSON.stringify([`gt:molecule-step`, `step:${i}`]), JSON.stringify({ step_index: i, step_data: step }), null, timestamp, timestamp, null, - ], + ] ); // Chain dependencies: each step blocks on the previous @@ -889,7 +840,7 @@ export function createMolecule( ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [stepId, prevStepId, "blocks"], + [stepId, prevStepId, 'blocks'] ); } prevStepId = stepId; @@ -903,35 +854,32 @@ export function createMolecule( SET ${beads.columns.metadata} = json_set(${beads.metadata}, '$.molecule_bead_id', ?) WHERE ${beads.bead_id} = ? `, - [id, beadId], + [id, beadId] ); const mol = getMolecule(sql, id); - if (!mol) throw new Error("Failed to create molecule"); + if (!mol) throw new Error('Failed to create molecule'); return mol; } /** * Get a molecule by its bead_id. Derives current_step and status from children. */ -export function getMolecule( - sql: SqlStorage, - moleculeId: string, -): Molecule | null { +export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | null { const bead = getBead(sql, moleculeId); - if (!bead || bead.type !== "molecule") return null; + if (!bead || bead.type !== 'molecule') return null; const steps = getStepBeads(sql, moleculeId); - const closedCount = steps.filter((s) => s.status === "closed").length; - const failedCount = steps.filter((s) => s.status === "failed").length; + const closedCount = steps.filter(s => s.status === 'closed').length; + const failedCount = steps.filter(s => s.status === 'failed').length; const currentStep = closedCount; const status = failedCount > 0 - ? "failed" + ? 'failed' : closedCount >= steps.length && steps.length > 0 - ? "completed" - : "active"; + ? 'completed' + : 'active'; const formula: unknown = bead.metadata?.formula ?? []; @@ -955,32 +903,29 @@ function getStepBeads(sql: SqlStorage, moleculeId: string): BeadRecord[] { WHERE ${beads.parent_bead_id} = ? ORDER BY ${beads.created_at} ASC `, - [moleculeId], + [moleculeId] ), ]; return BeadRecord.array().parse(rows); } -export function getMoleculeForBead( - sql: SqlStorage, - beadId: string, -): Molecule | null { +export function getMoleculeForBead(sql: SqlStorage, beadId: string): Molecule | null { const bead = getBead(sql, beadId); if (!bead) return null; const moleculeId: unknown = bead.metadata?.molecule_bead_id; - if (typeof moleculeId !== "string") return null; + if (typeof moleculeId !== 'string') return null; return getMolecule(sql, moleculeId); } export function getMoleculeCurrentStep( sql: SqlStorage, - agentId: string, + agentId: string ): { molecule: Molecule; step: unknown } | null { const agent = getAgent(sql, agentId); if (!agent?.current_hook_bead_id) return null; const mol = getMoleculeForBead(sql, agent.current_hook_bead_id); - if (!mol || mol.status !== "active") return null; + if (!mol || mol.status !== 'active') return null; const formula = mol.formula; if (!Array.isArray(formula)) return null; @@ -992,7 +937,7 @@ export function getMoleculeCurrentStep( export function advanceMoleculeStep( sql: SqlStorage, agentId: string, - _summary: string, + _summary: string ): Molecule | null { const current = getMoleculeCurrentStep(sql, agentId); if (!current) return null; @@ -1013,7 +958,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, currentStepBead.bead_id], + [timestamp, timestamp, currentStepBead.bead_id] ); } @@ -1034,7 +979,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, molecule.id], + [timestamp, timestamp, molecule.id] ); } From 7eecd8511bc9b1f433fda62f1194e454bfa33e48 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 21:16:47 -0500 Subject: [PATCH 7/8] fix(gastown): guard agentCompleted idle transition against re-dispatched agents agentCompleted unconditionally set the agent to idle, which could clobber a live dispatch if the agent was re-hooked and dispatched for new work between gt_done and the container's completion callback. Add a guard: don't set to idle if the agent is working AND has a hook (re-dispatched). Only set to idle if the agent is working with no hook (gt_done completed, waiting for process exit) or already idle. --- .../src/dos/town/review-queue.ts | 331 +++++++++++------- 1 file changed, 197 insertions(+), 134 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index de600bf2b..300afee2d 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -6,13 +6,17 @@ * - Molecules are parent beads with type='molecule' + child step beads */ -import { z } from 'zod'; -import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads.table'; -import { review_metadata } from '../../db/tables/review-metadata.table'; -import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; -import { agent_metadata } from '../../db/tables/agent-metadata.table'; -import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; -import { query } from '../../util/query.util'; +import { z } from "zod"; +import { + beads, + BeadRecord, + MergeRequestBeadRecord, +} from "../../db/tables/beads.table"; +import { review_metadata } from "../../db/tables/review-metadata.table"; +import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; +import { agent_metadata } from "../../db/tables/agent-metadata.table"; +import { convoy_metadata } from "../../db/tables/convoy-metadata.table"; +import { query } from "../../util/query.util"; import { logBeadEvent, getBead, @@ -23,10 +27,15 @@ import { getConvoyForBead, getConvoyFeatureBranch, getConvoyMergeMode, -} from './beads'; -import { getAgent, unhookBead, updateAgentStatus } from './agents'; -import { getRig } from './rigs'; -import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; +} from "./beads"; +import { getAgent, unhookBead, updateAgentStatus } from "./agents"; +import { getRig } from "./rigs"; +import type { + ReviewQueueInput, + ReviewQueueEntry, + AgentDoneInput, + Molecule, +} from "../../types"; // Review entries stuck in 'running' past this timeout are reset to 'pending'. // Only applies when no agent (working or idle) is hooked to the MR bead. @@ -65,29 +74,34 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { // The polecat that submitted the review — stored in metadata (not assignee, // which is set to the refinery when it claims the MR bead via hookBead). agent_id: - typeof row.metadata?.source_agent_id === 'string' + typeof row.metadata?.source_agent_id === "string" ? row.metadata.source_agent_id - : (row.created_by ?? ''), + : (row.created_by ?? ""), bead_id: - typeof row.metadata?.source_bead_id === 'string' ? row.metadata.source_bead_id : row.bead_id, - rig_id: row.rig_id ?? '', + typeof row.metadata?.source_bead_id === "string" + ? row.metadata.source_bead_id + : row.bead_id, + rig_id: row.rig_id ?? "", branch: row.branch, pr_url: row.pr_url, status: - row.status === 'open' - ? 'pending' - : row.status === 'in_progress' - ? 'running' - : row.status === 'closed' - ? 'merged' - : 'failed', + row.status === "open" + ? "pending" + : row.status === "in_progress" + ? "running" + : row.status === "closed" + ? "merged" + : "failed", summary: row.body, created_at: row.created_at, processed_at: row.updated_at === row.created_at ? null : row.updated_at, }; } -export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { +export function submitToReviewQueue( + sql: SqlStorage, + input: ReviewQueueInput, +): void { const id = generateId(); const timestamp = now(); @@ -107,12 +121,14 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v // - For standalone beads → rig's default branch // We pass defaultBranch from the caller so we don't hardcode 'main'. const convoyId = getConvoyForBead(sql, input.bead_id); - const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; + const convoyFeatureBranch = convoyId + ? getConvoyFeatureBranch(sql, convoyId) + : null; const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; const targetBranch = - convoyMergeMode === 'review-then-land' && convoyFeatureBranch + convoyMergeMode === "review-then-land" && convoyFeatureBranch ? convoyFeatureBranch - : (input.default_branch ?? 'main'); + : (input.default_branch ?? "main"); if (convoyId) { metadata.convoy_id = convoyId; @@ -136,21 +152,21 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v `, [ id, - 'merge_request', - 'open', + "merge_request", + "open", `Review: ${input.branch}`, input.summary ?? null, input.rig_id, null, null, // assignee left null — refinery claims it via hookBead - 'medium', - JSON.stringify(['gt:merge-request']), + "medium", + JSON.stringify(["gt:merge-request"]), JSON.stringify(metadata), input.agent_id, // created_by records who submitted timestamp, timestamp, null, - ] + ], ); // Link MR bead → source bead via bead_dependencies so the DAG is queryable @@ -163,7 +179,7 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, 'tracks') `, - [id, input.bead_id] + [id, input.bead_id], ); // Create the review_metadata satellite @@ -176,13 +192,13 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] + [id, input.branch, targetBranch, null, input.pr_url ?? null, 0], ); logBeadEvent(sql, { beadId: input.bead_id, agentId: input.agent_id, - eventType: 'review_submitted', + eventType: "review_submitted", newValue: input.branch, metadata: { branch: input.branch, target_branch: targetBranch }, }); @@ -212,7 +228,7 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ORDER BY ${beads.created_at} ASC LIMIT 1 `, - [] + [], ), ]; @@ -229,28 +245,28 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), entry.id] + [now(), entry.id], ); - return { ...entry, status: 'running', processed_at: now() }; + return { ...entry, status: "running", processed_at: now() }; } export function completeReview( sql: SqlStorage, entryId: string, - status: 'merged' | 'failed' + status: "merged" | "failed", ): void { // Guard: don't overwrite terminal states (closed MR bead that was // already merged should never be set to 'failed' by a stale call) const current = getBead(sql, entryId); - if (current && (current.status === 'closed' || current.status === 'failed')) { + if (current && (current.status === "closed" || current.status === "failed")) { console.warn( - `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping` + `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping`, ); return; } - const beadStatus = status === 'merged' ? 'closed' : 'failed'; + const beadStatus = status === "merged" ? "closed" : "failed"; const timestamp = now(); query( sql, @@ -261,7 +277,12 @@ export function completeReview( ${beads.columns.closed_at} = ? WHERE ${beads.bead_id} = ? `, - [beadStatus, timestamp, beadStatus === 'closed' ? timestamp : null, entryId] + [ + beadStatus, + timestamp, + beadStatus === "closed" ? timestamp : null, + entryId, + ], ); } @@ -272,18 +293,20 @@ export function completeReviewWithResult( sql: SqlStorage, input: { entry_id: string; - status: 'merged' | 'failed' | 'conflict'; + status: "merged" | "failed" | "conflict"; message?: string; commit_sha?: string; - } + }, ): void { // On conflict, mark the review entry as failed and create an escalation bead - const resolvedStatus = input.status === 'conflict' ? 'failed' : input.status; + const resolvedStatus = input.status === "conflict" ? "failed" : input.status; completeReview(sql, input.entry_id, resolvedStatus); // Find the review entry to get agent IDs const entryRows = [ - ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [input.entry_id]), + ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [ + input.entry_id, + ]), ]; if (entryRows.length === 0) return; const parsed = MergeRequestBeadRecord.parse(entryRows[0]); @@ -292,7 +315,7 @@ export function completeReviewWithResult( logBeadEvent(sql, { beadId: entry.bead_id, agentId: entry.agent_id, - eventType: 'review_completed', + eventType: "review_completed", newValue: input.status, metadata: { message: input.message, @@ -300,12 +323,12 @@ export function completeReviewWithResult( }, }); - if (input.status === 'merged') { + if (input.status === "merged") { const mergeTimestamp = now(); console.log( `[review-queue] completeReviewWithResult MERGED: entry_id=${input.entry_id} ` + `entry.bead_id (source)=${entry.bead_id} entry.id (MR)=${entry.id} — ` + - `calling closeBead on source` + `calling closeBead on source`, ); closeBead(sql, entry.bead_id, entry.agent_id); @@ -331,7 +354,7 @@ export function completeReviewWithResult( AND dep.${bead_dependencies.columns.dependency_type} = 'tracks' ) `, - [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id] + [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id], ); // closeBead → updateBeadStatus short-circuits when completeReview already @@ -342,7 +365,7 @@ export function completeReviewWithResult( // If this was a convoy landing MR, also set landed_at on the convoy metadata const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead?.type === 'convoy') { + if (sourceBead?.type === "convoy") { query( sql, /* sql */ ` @@ -350,16 +373,16 @@ export function completeReviewWithResult( SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [now(), entry.bead_id] + [now(), entry.bead_id], ); } - } else if (input.status === 'conflict') { + } else if (input.status === "conflict") { // Create an escalation bead so the conflict is visible and actionable createBead(sql, { - type: 'escalation', + type: "escalation", title: `Merge conflict: ${input.message ?? entry.branch}`, body: input.message, - priority: 'high', + priority: "high", metadata: { source_bead_id: entry.bead_id, source_agent_id: entry.agent_id, @@ -372,10 +395,10 @@ export function completeReviewWithResult( const conflictSourceBead = getBead(sql, entry.bead_id); if ( conflictSourceBead && - conflictSourceBead.status !== 'closed' && - conflictSourceBead.status !== 'failed' + conflictSourceBead.status !== "closed" && + conflictSourceBead.status !== "failed" ) { - updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); + updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); query( sql, /* sql */ ` @@ -383,10 +406,10 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id] + [entry.bead_id], ); } - } else if (input.status === 'failed') { + } else if (input.status === "failed") { // Review failed (rework requested): return source bead to open so // the normal scheduling path (feedStrandedConvoys → hookBead → // schedulePendingWork → dispatch) handles rework. Clear the stale @@ -394,8 +417,12 @@ export function completeReviewWithResult( // This avoids the fire-and-forget rework dispatch race in TownDO // where the dispatch fails and rehookOrphanedBeads churn. const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead && sourceBead.status !== 'closed' && sourceBead.status !== 'failed') { - updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); + if ( + sourceBead && + sourceBead.status !== "closed" && + sourceBead.status !== "failed" + ) { + updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); query( sql, /* sql */ ` @@ -403,7 +430,7 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id] + [entry.bead_id], ); } } @@ -418,7 +445,7 @@ export function completeReviewWithResult( /** Get review_metadata for an MR bead. */ export function getReviewMetadata( sql: SqlStorage, - mrBeadId: string + mrBeadId: string, ): { branch: string; target_branch: string; pr_url: string | null } | null { const rows = z .object({ @@ -437,17 +464,23 @@ export function getReviewMetadata( FROM ${review_metadata} WHERE ${review_metadata.bead_id} = ? `, - [mrBeadId] + [mrBeadId], ), ]); return rows[0] ?? null; } -export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): boolean { +export function setReviewPrUrl( + sql: SqlStorage, + entryId: string, + prUrl: string, +): boolean { // Reject non-HTTPS URLs to prevent storing garbage from LLM output. // Invalid URLs would cause pollPendingPRs to poll indefinitely. - if (!prUrl.startsWith('https://')) { - console.warn(`[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`); + if (!prUrl.startsWith("https://")) { + console.warn( + `[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`, + ); return false; } query( @@ -457,7 +490,7 @@ export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): SET ${review_metadata.columns.pr_url} = ? WHERE ${review_metadata.bead_id} = ? `, - [prUrl, entryId] + [prUrl, entryId], ); // Also write to bead metadata so the PR URL is visible in the standard bead list @@ -468,7 +501,7 @@ export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.pr_url', ?) WHERE ${beads.bead_id} = ? `, - [prUrl, entryId] + [prUrl, entryId], ); return true; } @@ -486,13 +519,17 @@ export function markReviewInReview(sql: SqlStorage, entryId: string): void { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [new Date().toISOString(), entryId] + [new Date().toISOString(), entryId], ); } // ── Agent Done ────────────────────────────────────────────────────── -export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { +export function agentDone( + sql: SqlStorage, + agentId: string, + input: AgentDoneInput, +): void { const agent = getAgent(sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) { @@ -504,7 +541,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // but the hook was cleared by zombie detection. We MUST still complete // the review — otherwise the source bead stays open forever. Find the // most recent non-closed MR bead assigned to this agent and complete it. - if (agent.role === 'refinery') { + if (agent.role === "refinery") { const recentMrRows = [ ...query( sql, @@ -517,13 +554,15 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu ORDER BY ${beads.updated_at} DESC LIMIT 1 `, - [agentId] + [agentId], ), ]; if (recentMrRows.length > 0) { - const mrBeadId = z.object({ bead_id: z.string() }).parse(recentMrRows[0]).bead_id; + const mrBeadId = z + .object({ bead_id: z.string() }) + .parse(recentMrRows[0]).bead_id; console.log( - `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}` + `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}`, ); if (input.pr_url) { const stored = setReviewPrUrl(sql, mrBeadId, input.pr_url); @@ -532,15 +571,17 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'failed', + status: "failed", message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); } } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'merged', - message: input.summary ?? 'Merged by refinery agent (recovered from unhook)', + status: "merged", + message: + input.summary ?? + "Merged by refinery agent (recovered from unhook)", }); } return; @@ -548,7 +589,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu } console.warn( - `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring` + `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring`, ); return; } @@ -558,7 +599,10 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // beads (created_by = 'patrol'). User-created beads that happen to carry // the gt:triage label go through normal review flow. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (hookedBead?.labels.includes('gt:triage') && hookedBead.created_by === 'patrol') { + if ( + hookedBead?.labels.includes("gt:triage") && + hookedBead.created_by === "patrol" + ) { closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; @@ -568,16 +612,16 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // to an existing branch (the one the refinery already reviewed). Closing // the rework bead unblocks the MR bead, and the reconciler re-dispatches // the refinery to re-review. - if (hookedBead?.labels.includes('gt:rework')) { + if (hookedBead?.labels.includes("gt:rework")) { console.log( - `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)` + `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)`, ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; } - if (agent.role === 'refinery') { + if (agent.role === "refinery") { // The refinery handles merging (direct strategy) or PR creation (pr strategy) // itself. When it calls gt_done: // - With pr_url: refinery created a PR → store URL, mark as in_review, poll it @@ -593,30 +637,30 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: 'pr_created', + eventType: "pr_created", newValue: input.pr_url, - metadata: { pr_url: input.pr_url, created_by: 'refinery' }, + metadata: { pr_url: input.pr_url, created_by: "refinery" }, }); } else { // Invalid URL — fail the review so it doesn't poll forever completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'failed', + status: "failed", message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: 'pr_creation_failed', - metadata: { pr_url: input.pr_url, reason: 'invalid_url' }, + eventType: "pr_creation_failed", + metadata: { pr_url: input.pr_url, reason: "invalid_url" }, }); } } else { // Direct strategy: refinery already merged and pushed completeReviewWithResult(sql, { entry_id: mrBeadId, - status: 'merged', - message: input.summary ?? 'Merged by refinery agent', + status: "merged", + message: input.summary ?? "Merged by refinery agent", }); } @@ -625,7 +669,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // refinery is available for new work. Without this, processReviewQueue // sees the refinery as 'working' and won't pop the next MR bead until // agentCompleted fires (when the container process eventually exits). - updateAgentStatus(sql, agentId, 'idle'); + updateAgentStatus(sql, agentId, "idle"); return; } @@ -633,13 +677,13 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu if (!agent.rig_id) { console.warn( - `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue` + `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue`, ); } // Resolve the rig's default branch so submitToReviewQueue can use it // instead of hardcoding 'main' for standalone/review-and-merge beads. - const rigId = agent.rig_id ?? ''; + const rigId = agent.rig_id ?? ""; const rig = rigId ? getRig(sql, rigId) : null; submitToReviewQueue(sql, { @@ -658,7 +702,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // worked on it. It will be closed (or returned to in_progress) by the // refinery after review. unhookBead(sql, agentId); - updateBeadStatus(sql, sourceBead, 'in_review', agentId); + updateBeadStatus(sql, sourceBead, "in_review", agentId); } /** @@ -681,14 +725,14 @@ export type AgentCompletedResult = { export function agentCompleted( sql: SqlStorage, agentId: string, - input: { status: 'completed' | 'failed'; reason?: string } + input: { status: "completed" | "failed"; reason?: string }, ): AgentCompletedResult { const result: AgentCompletedResult = { reworkSourceBeadId: null }; const agent = getAgent(sql, agentId); if (!agent) return result; if (agent.current_hook_bead_id) { - if (agent.role === 'refinery') { + if (agent.role === "refinery") { // NEVER fail or unhook a refinery from agentCompleted. // agentCompleted races with gt_done: the process exits, the // container sends /completed, but gt_done's HTTP request may @@ -709,16 +753,16 @@ export function agentCompleted( // before calling gt_done. Don't close the bead — just unhook. The reconciler's // Rule 3 will reset it to open after the staleness timeout. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (input.status === 'failed') { - updateBeadStatus(sql, agent.current_hook_bead_id, 'failed', agentId); - } else if (hookedBead && hookedBead.status === 'in_progress') { + if (input.status === "failed") { + updateBeadStatus(sql, agent.current_hook_bead_id, "failed", agentId); + } else if (hookedBead && hookedBead.status === "in_progress") { // Agent exited 'completed' but bead is still in_progress — gt_done was never called. // Don't close the bead. Rule 3 will handle rework. console.log( `[review-queue] agentCompleted: polecat ${agentId} exited without gt_done — ` + - `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)` + `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)`, ); - } else if (hookedBead && hookedBead.status === 'open') { + } else if (hookedBead && hookedBead.status === "open") { // Bead is open (wasn't dispatched yet or was already reset). No-op. } else { // Bead is in_review, closed, or failed — gt_done already ran. No-op on bead. @@ -727,7 +771,11 @@ export function agentCompleted( } } - // Mark agent idle. + // Mark agent idle — but ONLY if it hasn't been re-dispatched (status + // still 'working' on new work) since gt_done ran. agentCompleted can + // arrive after the agent has been re-hooked and dispatched for a new + // bead. Without this guard, the stale completion event would clobber + // the live dispatch. // For refineries, preserve dispatch_attempts so Rule 6's circuit-breaker // can track cumulative re-dispatch attempts across idle→dispatch cycles. // Resetting to 0 here was enabling infinite loops (#1342). Non-refineries @@ -742,8 +790,12 @@ export function agentCompleted( ELSE 0 END WHERE ${agent_metadata.bead_id} = ? + AND NOT ( + ${agent_metadata.columns.status} = 'working' + AND ${agent_metadata.columns.current_hook_bead_id} IS NOT NULL + ) `, - [agentId] + [agentId], ); return result; @@ -755,7 +807,11 @@ export function agentCompleted( * Create a molecule: a parent bead with type='molecule', child step beads * linked via parent_bead_id, and step ordering via bead_dependencies. */ -export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown): Molecule { +export function createMolecule( + sql: SqlStorage, + beadId: string, + formula: unknown, +): Molecule { const id = generateId(); const timestamp = now(); const formulaArr = Array.isArray(formula) ? formula : []; @@ -775,21 +831,21 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown `, [ id, - 'molecule', - 'open', + "molecule", + "open", `Molecule for bead ${beadId}`, null, null, null, null, - 'medium', - JSON.stringify(['gt:molecule']), + "medium", + JSON.stringify(["gt:molecule"]), JSON.stringify({ source_bead_id: beadId, formula }), null, timestamp, timestamp, null, - ] + ], ); // Create child step beads and dependency chain @@ -812,21 +868,22 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown `, [ stepId, - 'issue', - 'open', - z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, - typeof step === 'string' ? step : JSON.stringify(step), + "issue", + "open", + z.object({ title: z.string() }).safeParse(step).data?.title ?? + `Step ${i + 1}`, + typeof step === "string" ? step : JSON.stringify(step), null, id, null, - 'medium', + "medium", JSON.stringify([`gt:molecule-step`, `step:${i}`]), JSON.stringify({ step_index: i, step_data: step }), null, timestamp, timestamp, null, - ] + ], ); // Chain dependencies: each step blocks on the previous @@ -840,7 +897,7 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [stepId, prevStepId, 'blocks'] + [stepId, prevStepId, "blocks"], ); } prevStepId = stepId; @@ -854,32 +911,35 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown SET ${beads.columns.metadata} = json_set(${beads.metadata}, '$.molecule_bead_id', ?) WHERE ${beads.bead_id} = ? `, - [id, beadId] + [id, beadId], ); const mol = getMolecule(sql, id); - if (!mol) throw new Error('Failed to create molecule'); + if (!mol) throw new Error("Failed to create molecule"); return mol; } /** * Get a molecule by its bead_id. Derives current_step and status from children. */ -export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | null { +export function getMolecule( + sql: SqlStorage, + moleculeId: string, +): Molecule | null { const bead = getBead(sql, moleculeId); - if (!bead || bead.type !== 'molecule') return null; + if (!bead || bead.type !== "molecule") return null; const steps = getStepBeads(sql, moleculeId); - const closedCount = steps.filter(s => s.status === 'closed').length; - const failedCount = steps.filter(s => s.status === 'failed').length; + const closedCount = steps.filter((s) => s.status === "closed").length; + const failedCount = steps.filter((s) => s.status === "failed").length; const currentStep = closedCount; const status = failedCount > 0 - ? 'failed' + ? "failed" : closedCount >= steps.length && steps.length > 0 - ? 'completed' - : 'active'; + ? "completed" + : "active"; const formula: unknown = bead.metadata?.formula ?? []; @@ -903,29 +963,32 @@ function getStepBeads(sql: SqlStorage, moleculeId: string): BeadRecord[] { WHERE ${beads.parent_bead_id} = ? ORDER BY ${beads.created_at} ASC `, - [moleculeId] + [moleculeId], ), ]; return BeadRecord.array().parse(rows); } -export function getMoleculeForBead(sql: SqlStorage, beadId: string): Molecule | null { +export function getMoleculeForBead( + sql: SqlStorage, + beadId: string, +): Molecule | null { const bead = getBead(sql, beadId); if (!bead) return null; const moleculeId: unknown = bead.metadata?.molecule_bead_id; - if (typeof moleculeId !== 'string') return null; + if (typeof moleculeId !== "string") return null; return getMolecule(sql, moleculeId); } export function getMoleculeCurrentStep( sql: SqlStorage, - agentId: string + agentId: string, ): { molecule: Molecule; step: unknown } | null { const agent = getAgent(sql, agentId); if (!agent?.current_hook_bead_id) return null; const mol = getMoleculeForBead(sql, agent.current_hook_bead_id); - if (!mol || mol.status !== 'active') return null; + if (!mol || mol.status !== "active") return null; const formula = mol.formula; if (!Array.isArray(formula)) return null; @@ -937,7 +1000,7 @@ export function getMoleculeCurrentStep( export function advanceMoleculeStep( sql: SqlStorage, agentId: string, - _summary: string + _summary: string, ): Molecule | null { const current = getMoleculeCurrentStep(sql, agentId); if (!current) return null; @@ -958,7 +1021,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, currentStepBead.bead_id] + [timestamp, timestamp, currentStepBead.bead_id], ); } @@ -979,7 +1042,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, molecule.id] + [timestamp, timestamp, molecule.id], ); } From 726e2eae429c78a6142c3cedd2405f19f927907b Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 21:17:51 -0500 Subject: [PATCH 8/8] style: format review-queue.ts --- .../src/dos/town/review-queue.ts | 321 ++++++++---------- 1 file changed, 133 insertions(+), 188 deletions(-) diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 300afee2d..5eb1ad69f 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -6,17 +6,13 @@ * - Molecules are parent beads with type='molecule' + child step beads */ -import { z } from "zod"; -import { - beads, - BeadRecord, - MergeRequestBeadRecord, -} from "../../db/tables/beads.table"; -import { review_metadata } from "../../db/tables/review-metadata.table"; -import { bead_dependencies } from "../../db/tables/bead-dependencies.table"; -import { agent_metadata } from "../../db/tables/agent-metadata.table"; -import { convoy_metadata } from "../../db/tables/convoy-metadata.table"; -import { query } from "../../util/query.util"; +import { z } from 'zod'; +import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads.table'; +import { review_metadata } from '../../db/tables/review-metadata.table'; +import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; +import { agent_metadata } from '../../db/tables/agent-metadata.table'; +import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; +import { query } from '../../util/query.util'; import { logBeadEvent, getBead, @@ -27,15 +23,10 @@ import { getConvoyForBead, getConvoyFeatureBranch, getConvoyMergeMode, -} from "./beads"; -import { getAgent, unhookBead, updateAgentStatus } from "./agents"; -import { getRig } from "./rigs"; -import type { - ReviewQueueInput, - ReviewQueueEntry, - AgentDoneInput, - Molecule, -} from "../../types"; +} from './beads'; +import { getAgent, unhookBead, updateAgentStatus } from './agents'; +import { getRig } from './rigs'; +import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; // Review entries stuck in 'running' past this timeout are reset to 'pending'. // Only applies when no agent (working or idle) is hooked to the MR bead. @@ -74,34 +65,29 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { // The polecat that submitted the review — stored in metadata (not assignee, // which is set to the refinery when it claims the MR bead via hookBead). agent_id: - typeof row.metadata?.source_agent_id === "string" + typeof row.metadata?.source_agent_id === 'string' ? row.metadata.source_agent_id - : (row.created_by ?? ""), + : (row.created_by ?? ''), bead_id: - typeof row.metadata?.source_bead_id === "string" - ? row.metadata.source_bead_id - : row.bead_id, - rig_id: row.rig_id ?? "", + typeof row.metadata?.source_bead_id === 'string' ? row.metadata.source_bead_id : row.bead_id, + rig_id: row.rig_id ?? '', branch: row.branch, pr_url: row.pr_url, status: - row.status === "open" - ? "pending" - : row.status === "in_progress" - ? "running" - : row.status === "closed" - ? "merged" - : "failed", + row.status === 'open' + ? 'pending' + : row.status === 'in_progress' + ? 'running' + : row.status === 'closed' + ? 'merged' + : 'failed', summary: row.body, created_at: row.created_at, processed_at: row.updated_at === row.created_at ? null : row.updated_at, }; } -export function submitToReviewQueue( - sql: SqlStorage, - input: ReviewQueueInput, -): void { +export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { const id = generateId(); const timestamp = now(); @@ -121,14 +107,12 @@ export function submitToReviewQueue( // - For standalone beads → rig's default branch // We pass defaultBranch from the caller so we don't hardcode 'main'. const convoyId = getConvoyForBead(sql, input.bead_id); - const convoyFeatureBranch = convoyId - ? getConvoyFeatureBranch(sql, convoyId) - : null; + const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; const targetBranch = - convoyMergeMode === "review-then-land" && convoyFeatureBranch + convoyMergeMode === 'review-then-land' && convoyFeatureBranch ? convoyFeatureBranch - : (input.default_branch ?? "main"); + : (input.default_branch ?? 'main'); if (convoyId) { metadata.convoy_id = convoyId; @@ -152,21 +136,21 @@ export function submitToReviewQueue( `, [ id, - "merge_request", - "open", + 'merge_request', + 'open', `Review: ${input.branch}`, input.summary ?? null, input.rig_id, null, null, // assignee left null — refinery claims it via hookBead - "medium", - JSON.stringify(["gt:merge-request"]), + 'medium', + JSON.stringify(['gt:merge-request']), JSON.stringify(metadata), input.agent_id, // created_by records who submitted timestamp, timestamp, null, - ], + ] ); // Link MR bead → source bead via bead_dependencies so the DAG is queryable @@ -179,7 +163,7 @@ export function submitToReviewQueue( ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, 'tracks') `, - [id, input.bead_id], + [id, input.bead_id] ); // Create the review_metadata satellite @@ -192,13 +176,13 @@ export function submitToReviewQueue( ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.branch, targetBranch, null, input.pr_url ?? null, 0], + [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] ); logBeadEvent(sql, { beadId: input.bead_id, agentId: input.agent_id, - eventType: "review_submitted", + eventType: 'review_submitted', newValue: input.branch, metadata: { branch: input.branch, target_branch: targetBranch }, }); @@ -228,7 +212,7 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ORDER BY ${beads.created_at} ASC LIMIT 1 `, - [], + [] ), ]; @@ -245,28 +229,28 @@ export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [now(), entry.id], + [now(), entry.id] ); - return { ...entry, status: "running", processed_at: now() }; + return { ...entry, status: 'running', processed_at: now() }; } export function completeReview( sql: SqlStorage, entryId: string, - status: "merged" | "failed", + status: 'merged' | 'failed' ): void { // Guard: don't overwrite terminal states (closed MR bead that was // already merged should never be set to 'failed' by a stale call) const current = getBead(sql, entryId); - if (current && (current.status === "closed" || current.status === "failed")) { + if (current && (current.status === 'closed' || current.status === 'failed')) { console.warn( - `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping`, + `[review-queue] completeReview: bead ${entryId} already ${current.status}, skipping` ); return; } - const beadStatus = status === "merged" ? "closed" : "failed"; + const beadStatus = status === 'merged' ? 'closed' : 'failed'; const timestamp = now(); query( sql, @@ -277,12 +261,7 @@ export function completeReview( ${beads.columns.closed_at} = ? WHERE ${beads.bead_id} = ? `, - [ - beadStatus, - timestamp, - beadStatus === "closed" ? timestamp : null, - entryId, - ], + [beadStatus, timestamp, beadStatus === 'closed' ? timestamp : null, entryId] ); } @@ -293,20 +272,18 @@ export function completeReviewWithResult( sql: SqlStorage, input: { entry_id: string; - status: "merged" | "failed" | "conflict"; + status: 'merged' | 'failed' | 'conflict'; message?: string; commit_sha?: string; - }, + } ): void { // On conflict, mark the review entry as failed and create an escalation bead - const resolvedStatus = input.status === "conflict" ? "failed" : input.status; + const resolvedStatus = input.status === 'conflict' ? 'failed' : input.status; completeReview(sql, input.entry_id, resolvedStatus); // Find the review entry to get agent IDs const entryRows = [ - ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [ - input.entry_id, - ]), + ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [input.entry_id]), ]; if (entryRows.length === 0) return; const parsed = MergeRequestBeadRecord.parse(entryRows[0]); @@ -315,7 +292,7 @@ export function completeReviewWithResult( logBeadEvent(sql, { beadId: entry.bead_id, agentId: entry.agent_id, - eventType: "review_completed", + eventType: 'review_completed', newValue: input.status, metadata: { message: input.message, @@ -323,12 +300,12 @@ export function completeReviewWithResult( }, }); - if (input.status === "merged") { + if (input.status === 'merged') { const mergeTimestamp = now(); console.log( `[review-queue] completeReviewWithResult MERGED: entry_id=${input.entry_id} ` + `entry.bead_id (source)=${entry.bead_id} entry.id (MR)=${entry.id} — ` + - `calling closeBead on source`, + `calling closeBead on source` ); closeBead(sql, entry.bead_id, entry.agent_id); @@ -354,7 +331,7 @@ export function completeReviewWithResult( AND dep.${bead_dependencies.columns.dependency_type} = 'tracks' ) `, - [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id], + [mergeTimestamp, mergeTimestamp, input.entry_id, entry.bead_id] ); // closeBead → updateBeadStatus short-circuits when completeReview already @@ -365,7 +342,7 @@ export function completeReviewWithResult( // If this was a convoy landing MR, also set landed_at on the convoy metadata const sourceBead = getBead(sql, entry.bead_id); - if (sourceBead?.type === "convoy") { + if (sourceBead?.type === 'convoy') { query( sql, /* sql */ ` @@ -373,16 +350,16 @@ export function completeReviewWithResult( SET ${convoy_metadata.columns.landed_at} = ? WHERE ${convoy_metadata.bead_id} = ? `, - [now(), entry.bead_id], + [now(), entry.bead_id] ); } - } else if (input.status === "conflict") { + } else if (input.status === 'conflict') { // Create an escalation bead so the conflict is visible and actionable createBead(sql, { - type: "escalation", + type: 'escalation', title: `Merge conflict: ${input.message ?? entry.branch}`, body: input.message, - priority: "high", + priority: 'high', metadata: { source_bead_id: entry.bead_id, source_agent_id: entry.agent_id, @@ -395,10 +372,10 @@ export function completeReviewWithResult( const conflictSourceBead = getBead(sql, entry.bead_id); if ( conflictSourceBead && - conflictSourceBead.status !== "closed" && - conflictSourceBead.status !== "failed" + conflictSourceBead.status !== 'closed' && + conflictSourceBead.status !== 'failed' ) { - updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); + updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); query( sql, /* sql */ ` @@ -406,10 +383,10 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id], + [entry.bead_id] ); } - } else if (input.status === "failed") { + } else if (input.status === 'failed') { // Review failed (rework requested): return source bead to open so // the normal scheduling path (feedStrandedConvoys → hookBead → // schedulePendingWork → dispatch) handles rework. Clear the stale @@ -417,12 +394,8 @@ export function completeReviewWithResult( // This avoids the fire-and-forget rework dispatch race in TownDO // where the dispatch fails and rehookOrphanedBeads churn. const sourceBead = getBead(sql, entry.bead_id); - if ( - sourceBead && - sourceBead.status !== "closed" && - sourceBead.status !== "failed" - ) { - updateBeadStatus(sql, entry.bead_id, "open", entry.agent_id); + if (sourceBead && sourceBead.status !== 'closed' && sourceBead.status !== 'failed') { + updateBeadStatus(sql, entry.bead_id, 'open', entry.agent_id); query( sql, /* sql */ ` @@ -430,7 +403,7 @@ export function completeReviewWithResult( SET ${beads.columns.assignee_agent_bead_id} = NULL WHERE ${beads.bead_id} = ? `, - [entry.bead_id], + [entry.bead_id] ); } } @@ -445,7 +418,7 @@ export function completeReviewWithResult( /** Get review_metadata for an MR bead. */ export function getReviewMetadata( sql: SqlStorage, - mrBeadId: string, + mrBeadId: string ): { branch: string; target_branch: string; pr_url: string | null } | null { const rows = z .object({ @@ -464,23 +437,17 @@ export function getReviewMetadata( FROM ${review_metadata} WHERE ${review_metadata.bead_id} = ? `, - [mrBeadId], + [mrBeadId] ), ]); return rows[0] ?? null; } -export function setReviewPrUrl( - sql: SqlStorage, - entryId: string, - prUrl: string, -): boolean { +export function setReviewPrUrl(sql: SqlStorage, entryId: string, prUrl: string): boolean { // Reject non-HTTPS URLs to prevent storing garbage from LLM output. // Invalid URLs would cause pollPendingPRs to poll indefinitely. - if (!prUrl.startsWith("https://")) { - console.warn( - `[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`, - ); + if (!prUrl.startsWith('https://')) { + console.warn(`[review-queue] setReviewPrUrl: rejecting non-HTTPS pr_url: ${prUrl}`); return false; } query( @@ -490,7 +457,7 @@ export function setReviewPrUrl( SET ${review_metadata.columns.pr_url} = ? WHERE ${review_metadata.bead_id} = ? `, - [prUrl, entryId], + [prUrl, entryId] ); // Also write to bead metadata so the PR URL is visible in the standard bead list @@ -501,7 +468,7 @@ export function setReviewPrUrl( SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.pr_url', ?) WHERE ${beads.bead_id} = ? `, - [prUrl, entryId], + [prUrl, entryId] ); return true; } @@ -519,17 +486,13 @@ export function markReviewInReview(sql: SqlStorage, entryId: string): void { ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [new Date().toISOString(), entryId], + [new Date().toISOString(), entryId] ); } // ── Agent Done ────────────────────────────────────────────────────── -export function agentDone( - sql: SqlStorage, - agentId: string, - input: AgentDoneInput, -): void { +export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { const agent = getAgent(sql, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) { @@ -541,7 +504,7 @@ export function agentDone( // but the hook was cleared by zombie detection. We MUST still complete // the review — otherwise the source bead stays open forever. Find the // most recent non-closed MR bead assigned to this agent and complete it. - if (agent.role === "refinery") { + if (agent.role === 'refinery') { const recentMrRows = [ ...query( sql, @@ -554,15 +517,13 @@ export function agentDone( ORDER BY ${beads.updated_at} DESC LIMIT 1 `, - [agentId], + [agentId] ), ]; if (recentMrRows.length > 0) { - const mrBeadId = z - .object({ bead_id: z.string() }) - .parse(recentMrRows[0]).bead_id; + const mrBeadId = z.object({ bead_id: z.string() }).parse(recentMrRows[0]).bead_id; console.log( - `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}`, + `[review-queue] agentDone: unhooked refinery ${agentId} — recovering MR bead ${mrBeadId}` ); if (input.pr_url) { const stored = setReviewPrUrl(sql, mrBeadId, input.pr_url); @@ -571,17 +532,15 @@ export function agentDone( } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "failed", + status: 'failed', message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); } } else { completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "merged", - message: - input.summary ?? - "Merged by refinery agent (recovered from unhook)", + status: 'merged', + message: input.summary ?? 'Merged by refinery agent (recovered from unhook)', }); } return; @@ -589,7 +548,7 @@ export function agentDone( } console.warn( - `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring`, + `[review-queue] agentDone: agent ${agentId} (role=${agent.role}) has no hooked bead — ignoring` ); return; } @@ -599,10 +558,7 @@ export function agentDone( // beads (created_by = 'patrol'). User-created beads that happen to carry // the gt:triage label go through normal review flow. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if ( - hookedBead?.labels.includes("gt:triage") && - hookedBead.created_by === "patrol" - ) { + if (hookedBead?.labels.includes('gt:triage') && hookedBead.created_by === 'patrol') { closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; @@ -612,16 +568,16 @@ export function agentDone( // to an existing branch (the one the refinery already reviewed). Closing // the rework bead unblocks the MR bead, and the reconciler re-dispatches // the refinery to re-review. - if (hookedBead?.labels.includes("gt:rework")) { + if (hookedBead?.labels.includes('gt:rework')) { console.log( - `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)`, + `[review-queue] agentDone: rework bead ${agent.current_hook_bead_id} — closing directly (skip review)` ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); return; } - if (agent.role === "refinery") { + if (agent.role === 'refinery') { // The refinery handles merging (direct strategy) or PR creation (pr strategy) // itself. When it calls gt_done: // - With pr_url: refinery created a PR → store URL, mark as in_review, poll it @@ -637,30 +593,30 @@ export function agentDone( logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: "pr_created", + eventType: 'pr_created', newValue: input.pr_url, - metadata: { pr_url: input.pr_url, created_by: "refinery" }, + metadata: { pr_url: input.pr_url, created_by: 'refinery' }, }); } else { // Invalid URL — fail the review so it doesn't poll forever completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "failed", + status: 'failed', message: `Refinery provided invalid pr_url: ${input.pr_url}`, }); logBeadEvent(sql, { beadId: mrBeadId, agentId, - eventType: "pr_creation_failed", - metadata: { pr_url: input.pr_url, reason: "invalid_url" }, + eventType: 'pr_creation_failed', + metadata: { pr_url: input.pr_url, reason: 'invalid_url' }, }); } } else { // Direct strategy: refinery already merged and pushed completeReviewWithResult(sql, { entry_id: mrBeadId, - status: "merged", - message: input.summary ?? "Merged by refinery agent", + status: 'merged', + message: input.summary ?? 'Merged by refinery agent', }); } @@ -669,7 +625,7 @@ export function agentDone( // refinery is available for new work. Without this, processReviewQueue // sees the refinery as 'working' and won't pop the next MR bead until // agentCompleted fires (when the container process eventually exits). - updateAgentStatus(sql, agentId, "idle"); + updateAgentStatus(sql, agentId, 'idle'); return; } @@ -677,13 +633,13 @@ export function agentDone( if (!agent.rig_id) { console.warn( - `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue`, + `[review-queue] agentDone: agent ${agentId} has null rig_id — review entry may fail in processReviewQueue` ); } // Resolve the rig's default branch so submitToReviewQueue can use it // instead of hardcoding 'main' for standalone/review-and-merge beads. - const rigId = agent.rig_id ?? ""; + const rigId = agent.rig_id ?? ''; const rig = rigId ? getRig(sql, rigId) : null; submitToReviewQueue(sql, { @@ -702,7 +658,7 @@ export function agentDone( // worked on it. It will be closed (or returned to in_progress) by the // refinery after review. unhookBead(sql, agentId); - updateBeadStatus(sql, sourceBead, "in_review", agentId); + updateBeadStatus(sql, sourceBead, 'in_review', agentId); } /** @@ -725,14 +681,14 @@ export type AgentCompletedResult = { export function agentCompleted( sql: SqlStorage, agentId: string, - input: { status: "completed" | "failed"; reason?: string }, + input: { status: 'completed' | 'failed'; reason?: string } ): AgentCompletedResult { const result: AgentCompletedResult = { reworkSourceBeadId: null }; const agent = getAgent(sql, agentId); if (!agent) return result; if (agent.current_hook_bead_id) { - if (agent.role === "refinery") { + if (agent.role === 'refinery') { // NEVER fail or unhook a refinery from agentCompleted. // agentCompleted races with gt_done: the process exits, the // container sends /completed, but gt_done's HTTP request may @@ -753,16 +709,16 @@ export function agentCompleted( // before calling gt_done. Don't close the bead — just unhook. The reconciler's // Rule 3 will reset it to open after the staleness timeout. const hookedBead = getBead(sql, agent.current_hook_bead_id); - if (input.status === "failed") { - updateBeadStatus(sql, agent.current_hook_bead_id, "failed", agentId); - } else if (hookedBead && hookedBead.status === "in_progress") { + if (input.status === 'failed') { + updateBeadStatus(sql, agent.current_hook_bead_id, 'failed', agentId); + } else if (hookedBead && hookedBead.status === 'in_progress') { // Agent exited 'completed' but bead is still in_progress — gt_done was never called. // Don't close the bead. Rule 3 will handle rework. console.log( `[review-queue] agentCompleted: polecat ${agentId} exited without gt_done — ` + - `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)`, + `bead ${agent.current_hook_bead_id} stays in_progress (Rule 3 will recover)` ); - } else if (hookedBead && hookedBead.status === "open") { + } else if (hookedBead && hookedBead.status === 'open') { // Bead is open (wasn't dispatched yet or was already reset). No-op. } else { // Bead is in_review, closed, or failed — gt_done already ran. No-op on bead. @@ -795,7 +751,7 @@ export function agentCompleted( AND ${agent_metadata.columns.current_hook_bead_id} IS NOT NULL ) `, - [agentId], + [agentId] ); return result; @@ -807,11 +763,7 @@ export function agentCompleted( * Create a molecule: a parent bead with type='molecule', child step beads * linked via parent_bead_id, and step ordering via bead_dependencies. */ -export function createMolecule( - sql: SqlStorage, - beadId: string, - formula: unknown, -): Molecule { +export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown): Molecule { const id = generateId(); const timestamp = now(); const formulaArr = Array.isArray(formula) ? formula : []; @@ -831,21 +783,21 @@ export function createMolecule( `, [ id, - "molecule", - "open", + 'molecule', + 'open', `Molecule for bead ${beadId}`, null, null, null, null, - "medium", - JSON.stringify(["gt:molecule"]), + 'medium', + JSON.stringify(['gt:molecule']), JSON.stringify({ source_bead_id: beadId, formula }), null, timestamp, timestamp, null, - ], + ] ); // Create child step beads and dependency chain @@ -868,22 +820,21 @@ export function createMolecule( `, [ stepId, - "issue", - "open", - z.object({ title: z.string() }).safeParse(step).data?.title ?? - `Step ${i + 1}`, - typeof step === "string" ? step : JSON.stringify(step), + 'issue', + 'open', + z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, + typeof step === 'string' ? step : JSON.stringify(step), null, id, null, - "medium", + 'medium', JSON.stringify([`gt:molecule-step`, `step:${i}`]), JSON.stringify({ step_index: i, step_data: step }), null, timestamp, timestamp, null, - ], + ] ); // Chain dependencies: each step blocks on the previous @@ -897,7 +848,7 @@ export function createMolecule( ${bead_dependencies.columns.dependency_type} ) VALUES (?, ?, ?) `, - [stepId, prevStepId, "blocks"], + [stepId, prevStepId, 'blocks'] ); } prevStepId = stepId; @@ -911,35 +862,32 @@ export function createMolecule( SET ${beads.columns.metadata} = json_set(${beads.metadata}, '$.molecule_bead_id', ?) WHERE ${beads.bead_id} = ? `, - [id, beadId], + [id, beadId] ); const mol = getMolecule(sql, id); - if (!mol) throw new Error("Failed to create molecule"); + if (!mol) throw new Error('Failed to create molecule'); return mol; } /** * Get a molecule by its bead_id. Derives current_step and status from children. */ -export function getMolecule( - sql: SqlStorage, - moleculeId: string, -): Molecule | null { +export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | null { const bead = getBead(sql, moleculeId); - if (!bead || bead.type !== "molecule") return null; + if (!bead || bead.type !== 'molecule') return null; const steps = getStepBeads(sql, moleculeId); - const closedCount = steps.filter((s) => s.status === "closed").length; - const failedCount = steps.filter((s) => s.status === "failed").length; + const closedCount = steps.filter(s => s.status === 'closed').length; + const failedCount = steps.filter(s => s.status === 'failed').length; const currentStep = closedCount; const status = failedCount > 0 - ? "failed" + ? 'failed' : closedCount >= steps.length && steps.length > 0 - ? "completed" - : "active"; + ? 'completed' + : 'active'; const formula: unknown = bead.metadata?.formula ?? []; @@ -963,32 +911,29 @@ function getStepBeads(sql: SqlStorage, moleculeId: string): BeadRecord[] { WHERE ${beads.parent_bead_id} = ? ORDER BY ${beads.created_at} ASC `, - [moleculeId], + [moleculeId] ), ]; return BeadRecord.array().parse(rows); } -export function getMoleculeForBead( - sql: SqlStorage, - beadId: string, -): Molecule | null { +export function getMoleculeForBead(sql: SqlStorage, beadId: string): Molecule | null { const bead = getBead(sql, beadId); if (!bead) return null; const moleculeId: unknown = bead.metadata?.molecule_bead_id; - if (typeof moleculeId !== "string") return null; + if (typeof moleculeId !== 'string') return null; return getMolecule(sql, moleculeId); } export function getMoleculeCurrentStep( sql: SqlStorage, - agentId: string, + agentId: string ): { molecule: Molecule; step: unknown } | null { const agent = getAgent(sql, agentId); if (!agent?.current_hook_bead_id) return null; const mol = getMoleculeForBead(sql, agent.current_hook_bead_id); - if (!mol || mol.status !== "active") return null; + if (!mol || mol.status !== 'active') return null; const formula = mol.formula; if (!Array.isArray(formula)) return null; @@ -1000,7 +945,7 @@ export function getMoleculeCurrentStep( export function advanceMoleculeStep( sql: SqlStorage, agentId: string, - _summary: string, + _summary: string ): Molecule | null { const current = getMoleculeCurrentStep(sql, agentId); if (!current) return null; @@ -1021,7 +966,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, currentStepBead.bead_id], + [timestamp, timestamp, currentStepBead.bead_id] ); } @@ -1042,7 +987,7 @@ export function advanceMoleculeStep( ${beads.columns.updated_at} = ? WHERE ${beads.bead_id} = ? `, - [timestamp, timestamp, molecule.id], + [timestamp, timestamp, molecule.id] ); }