Skip to content

Commit 49aa8cc

Browse files
authored
feat(gastown): replace binary is_admin gate with PostHog feature flags (#904)
* feat(gastown): replace binary is_admin gate with PostHog feature flags (#901) Replace the binary is_admin check from #537 with PostHog feature flags for progressive rollout. Flag management (allowlists, percentage rollout, kill-switch) is handled entirely through the PostHog dashboard — no custom DB tables or admin UI needed. Gate points updated: - 9 Next.js pages use isFeatureFlagEnabled('gastown-access', user.id) - Sidebar uses useFeatureFlagEnabled('gastown-access') - Token endpoint evaluates the flag and embeds gastownAccess in the JWT - Worker checks gastownAccess JWT claim (isAdmin fallback for compat) Sub-feature flag names defined: gastown-convoys, gastown-pr-merge, gastown-multi-rig (to be created in PostHog when needed). Closes #901 * fix: address PR review comments - Switch from isFeatureFlagEnabled to isReleaseToggleEnabled for strict boolean auth checks (prevents multivariate variants from granting access) - Remove dev-mode bypass — gate in dev too via PostHog - Abstract requireGastownAccess into gastownProcedure composable tRPC middleware in init.ts, replacing manual requireGastownAccess(ctx) calls - Remove sub-feature flags (convoys, pr_merge, multi_rig) — only gastown-access remains * fix: use strict boolean check for client-side gastown flag Use useFeatureFlagVariantKey === true instead of useFeatureFlagEnabled to align the sidebar with the server-side isReleaseToggleEnabled check. This prevents multivariate string variants from showing the nav item when server-side access would be denied. * fix: use isFeatureFlagEnabled with dev override for gastown-access Switch all gate points from isReleaseToggleEnabled back to isFeatureFlagEnabled. Add a DEV_ENABLED_FLAGS set to posthog-feature-flags.ts that returns true for gastown-access in non-production environments so local dev works without PostHog configuration. Sidebar reverts to useFeatureFlagEnabled. * refactor: move dev override into isGastownEnabled, revert posthog-feature-flags.ts Move the dev-mode override out of the shared posthog-feature-flags.ts module and into isGastownEnabled in src/lib/gastown/feature-flags.ts. All pages and the token endpoint now call isGastownEnabled(user.id) which returns true in non-production and delegates to isFeatureFlagEnabled in production. The sidebar uses useFeatureFlagEnabled || isDevelopment for the same effect client-side. * feat(gastown): witness/deacon patrol, mayor codebase browsing, and UI improvements (#442) (#924) Alarm-driven patrol system (witness & deacon): - Tiered GUPP violation handling (30min warn, 1h escalate+triage, 2h force-stop) - Orphaned work detection, stale hook recovery, agent GC, crash loop detection - Per-bead timeout enforcement with agent container termination - On-demand LLM triage agent for ambiguous situations - Triage action validation, access control, and snapshot-based resolution - Stranded convoy feeding with immediate dispatch eligibility Mayor codebase browsing: - Browse worktrees at /workspace/rigs/<rigId>/browse/ for read-only access - POST /repos/setup container endpoint for proactive repo cloning - System prompt written to AGENTS.md so mayor and sub-agents share context - Git credential race fix: refreshGitCredentials runs before configureRig - GIT_TERMINAL_PROMPT=0 to prevent credential prompt hangs Agent dispatch improvements: - startPoint parameter for convoy agents to branch from feature branch - platformIntegrationId and KILOCODE_TOKEN plumbed through repo setup - Existing users arm watchdog on DO init - RESTART_WITH_BACKOFF uses dispatch cooldown delay Rig deletion fix: - tRPC deleteRig now calls TownDO.removeRig (was missing) - addRig handles stale name conflicts via catch-and-retry Real-time alarm status UI: - Hibernatable WebSocket for live alarm status push - Status tab in terminal bar with agent/bead/patrol cards Other UI: - Convoy title and branch use flex-based truncation instead of fixed max-width - Status pane card padding normalized to p-2 - Legacy agent roles accepted in Zod schemas for backward compat - PostHog feature flag integration for gastown access gating * fix(gastown): only stop agent on triage resolve if still hooked to snapshot bead CLOSE_BEAD and REASSIGN_BEAD now check that the agent's current hook matches the snapshot bead from the triage request before calling stopAgentInContainer. If the agent has moved on to different work, stopping it would abort unrelated sessions. * style: fix prettier formatting in gastown type declarations * fix: resolve lint errors (no-base-to-string, unused vars) * fix(gastown): triage agent should call gt_done not gt_bead_close gt_bead_close only marks the bead closed without unhooking the agent or resetting it to idle, leaking agent records. gt_done triggers the agentDone path which has the patrol-created triage fast-path that properly closes the batch, unhooks, and returns the agent to idle. * fix(gastown): refinery singleton, container eviction recovery, review queue safety - Treat refinery as per-rig singleton in getOrCreateAgent to prevent UNIQUE constraint on identity when a refinery already exists - Re-queue review entry (reset to open) when refinery is busy instead of leaving it stuck in in_progress - Return 'not_found' (not 'unknown') from checkAgentContainerStatus on 404, so witnessPatrol immediately resets and redispatches agents after container eviction instead of waiting for the 2-hour GUPP timeout * fix(gastown): triage prompt, timestamp format, per-rig creds, browse refresh - Remove remaining gt_bead_close reference in triage prompt (line 72) that contradicted the gt_done instruction on line 49 - Use strftime with ISO format in orphanedHooks SQL query to match the toISOString() format stored in last_activity_at - Resolve git credentials per-rig in mayor browse setup instead of sharing one credential set across all rigs - Browse worktree refresh uses fetch+reset instead of checkout to avoid wrong-branch errors (worktree is on synthetic browse branch) * fix(gastown): escalations now create triage requests for automated follow-up Previously, gt_escalate created an escalation bead and optionally notified the mayor, but nothing automated acted on it. Escalation beads sat open with no assignee indefinitely. Now routeEscalation creates a triage request alongside the escalation bead, feeding the escalation into the patrol→triage→resolve loop. The triage agent can then RESTART, REASSIGN, CLOSE, or ESCALATE_TO_MAYOR with the full context of the original escalation. When a triage request linked to an escalation is resolved, the escalation bead is also closed automatically. Also adds 'escalation' to the TriageType union and enriches the ESCALATE_TO_MAYOR mayor message with agent and bead context. * feat(gastown): store convoy_id and source_bead_id in escalation metadata When an agent escalates from within a convoy, the escalation bead and its triage request now carry convoy_id and source_bead_id in their metadata. This associates escalations with their convoy for display purposes and lays groundwork for Phase 4 convoy-aware triage handling. * fix(gastown): escalation metadata path, polling fallback, refinery rollback, regen types - Fix escalation_bead_id lookup in resolveTriage to read from metadata.context (matching createTriageRequest's structure) - Add polling fallback to AlarmStatusPane via tRPC getAlarmStatus query when WebSocket fails, with 5s refetch interval - Reset refinery to idle when container start fails in processReviewQueue - Regenerate gastown type declarations to include getAlarmStatus
1 parent 179a553 commit 49aa8cc

50 files changed

Lines changed: 3069 additions & 279 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cloudflare-gastown/container/plugin/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,21 @@ export class GastownClient {
174174
body: JSON.stringify({ summary }),
175175
});
176176
}
177+
178+
/**
179+
* Resolve a triage_request bead with the chosen action and notes.
180+
* The TownDO closes the triage request and executes any side effects.
181+
*/
182+
async resolveTriage(input: {
183+
triage_request_bead_id: string;
184+
action: string;
185+
resolution_notes: string;
186+
}): Promise<Bead> {
187+
return this.request<Bead>(this.rigPath('/triage/resolve'), {
188+
method: 'POST',
189+
body: JSON.stringify(input),
190+
});
191+
}
177192
}
178193

179194
/**

cloudflare-gastown/container/plugin/tools.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,32 @@ export function createTools(client: GastownClient) {
184184
return `Advanced to step ${result.currentStep + 1} of ${result.totalSteps}.`;
185185
},
186186
}),
187+
188+
gt_triage_resolve: tool({
189+
description:
190+
'Resolve a triage request with your chosen action. The TownDO will execute ' +
191+
'the action (restart agent, close bead, escalate, etc.) and close the triage request.',
192+
args: {
193+
triage_request_bead_id: tool.schema
194+
.string()
195+
.describe('The UUID of the triage_request bead to resolve'),
196+
action: tool.schema
197+
.string()
198+
.describe(
199+
'The chosen action from the available options (e.g. RESTART, ESCALATE_TO_MAYOR, CLOSE_BEAD)'
200+
),
201+
resolution_notes: tool.schema
202+
.string()
203+
.describe('Brief explanation of your reasoning for choosing this action'),
204+
},
205+
async execute(args) {
206+
const bead = await client.resolveTriage({
207+
triage_request_bead_id: args.triage_request_bead_id,
208+
action: args.action,
209+
resolution_notes: args.resolution_notes,
210+
});
211+
return `Triage request ${args.triage_request_bead_id} resolved with action: ${args.action}`;
212+
},
213+
}),
187214
};
188215
}

cloudflare-gastown/container/plugin/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export type Bead = {
3030
closed_at: string | null;
3131
};
3232

33-
export type AgentRole = 'polecat' | 'refinery' | 'mayor' | 'witness';
33+
export type AgentRole = 'polecat' | 'refinery' | 'mayor';
3434
export type AgentStatus = 'idle' | 'working' | 'stalled' | 'dead';
3535

3636
export type Agent = {

cloudflare-gastown/container/src/agent-runner.ts

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Config } from '@kilocode/sdk';
22
import { z } from 'zod';
33
import { writeFile } from 'node:fs/promises';
4-
import { cloneRepo, createWorktree } from './git-manager';
4+
import { cloneRepo, createWorktree, setupRigBrowseWorktree } from './git-manager';
55
import { startAgent } from './process-manager';
66
import { getCurrentTownConfig } from './control-server';
77
import type { ManagedAgent, StartAgentRequest } from './types';
@@ -210,15 +210,16 @@ async function configureGitCredentials(
210210
* is available, call the Next.js server to resolve fresh credentials.
211211
* Returns the (potentially enriched) envVars.
212212
*/
213-
async function resolveGitCredentialsIfMissing(
214-
request: StartAgentRequest
215-
): Promise<Record<string, string>> {
216-
const envVars = { ...(request.envVars ?? {}) };
213+
export async function resolveGitCredentials(params: {
214+
envVars?: Record<string, string>;
215+
platformIntegrationId?: string;
216+
}): Promise<Record<string, string>> {
217+
const envVars = { ...(params.envVars ?? {}) };
217218
const hasToken = !!(envVars.GIT_TOKEN || envVars.GITHUB_TOKEN || envVars.GITLAB_TOKEN);
218219

219220
if (hasToken) return envVars;
220221

221-
const integrationId = request.platformIntegrationId;
222+
const integrationId = params.platformIntegrationId;
222223
const kiloToken = envVars.KILOCODE_TOKEN;
223224
// The Next.js server URL — in dev it's localhost:3000, in prod it's the main app URL.
224225
// We derive it from KILO_API_URL (the gateway URL) or fall back to localhost.
@@ -360,6 +361,58 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
360361
return dir;
361362
}
362363

364+
/**
365+
* Write the mayor's system prompt to AGENTS.md in the workspace.
366+
*
367+
* kilo/opencode reads AGENTS.md from the project root for ALL sessions,
368+
* including built-in sub-agents (explore, general). By writing the full
369+
* system prompt here instead of passing it via the session.prompt API,
370+
* the mayor and all its sub-agents share the exact same instructions.
371+
*
372+
* The system prompt comes from the TownDO (buildMayorSystemPrompt) and
373+
* is the single source of truth. When it changes (gastown updates,
374+
* user customization), the TownDO sends the updated prompt and we
375+
* rewrite this file.
376+
*/
377+
async function writeMayorSystemPromptToAgentsMd(
378+
workspaceDir: string,
379+
systemPrompt: string
380+
): Promise<void> {
381+
const { writeFile, readdir, stat } = await import('node:fs/promises');
382+
const path = await import('node:path');
383+
384+
// Append a dynamic section listing discovered browse worktrees so
385+
// sub-agents know where to find rig codebases.
386+
const rigsRoot = '/workspace/rigs';
387+
let rigDirs: string[] = [];
388+
try {
389+
rigDirs = await readdir(rigsRoot);
390+
} catch {
391+
// No rigs directory yet
392+
}
393+
394+
const browseEntries: string[] = [];
395+
for (const entry of rigDirs) {
396+
if (entry.startsWith('mayor-')) continue;
397+
const browseDir = path.join(rigsRoot, entry, 'browse');
398+
try {
399+
const s = await stat(browseDir);
400+
if (s.isDirectory()) {
401+
browseEntries.push(`- **${entry}**: \`${browseDir}\``);
402+
}
403+
} catch {
404+
// No browse worktree yet
405+
}
406+
}
407+
408+
const browseSuffix =
409+
browseEntries.length > 0
410+
? `\n\n## Discovered Browse Worktrees\n\n${browseEntries.join('\n')}`
411+
: '';
412+
413+
await writeFile(path.join(workspaceDir, 'AGENTS.md'), systemPrompt + browseSuffix);
414+
}
415+
363416
/**
364417
* Run the full agent startup sequence:
365418
* 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor)
@@ -368,17 +421,60 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
368421
* 4. Start a kilo serve instance for the worktree (or reuse existing)
369422
* 5. Create a session and send the initial prompt via HTTP API
370423
*/
371-
export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent> {
424+
export async function runAgent(originalRequest: StartAgentRequest): Promise<ManagedAgent> {
425+
let request = originalRequest;
372426
let workdir: string;
373427

374428
if (request.role === 'mayor') {
375429
// Mayor doesn't need a repo clone — just a git-initialized directory
376430
workdir = await createMayorWorkspace(request.rigId);
431+
432+
// On fresh containers the browse worktrees won't exist yet. Set them
433+
// up for all known rigs before writing AGENTS.md so the mayor (and its
434+
// sub-agents) can immediately browse codebases.
435+
if (request.rigs?.length) {
436+
// Resolve credentials per-rig since each may use a different
437+
// GitHub App installation (platformIntegrationId).
438+
const baseEnvVars = request.envVars ?? {};
439+
await Promise.allSettled(
440+
request.rigs.map(async rig => {
441+
try {
442+
const envVars = await resolveGitCredentials({
443+
envVars: baseEnvVars,
444+
platformIntegrationId: rig.platformIntegrationId,
445+
});
446+
await setupRigBrowseWorktree({
447+
rigId: rig.rigId,
448+
gitUrl: rig.gitUrl,
449+
defaultBranch: rig.defaultBranch,
450+
envVars,
451+
});
452+
} catch (err) {
453+
const msg = err instanceof Error ? err.message.split('\n')[0] : String(err);
454+
console.warn(`[runAgent] browse worktree setup failed for rig=${rig.rigId}: ${msg}`);
455+
}
456+
})
457+
);
458+
}
459+
460+
// Write the system prompt to AGENTS.md so the mayor AND its built-in
461+
// sub-agents (explore, general) all share the same instructions.
462+
// The system prompt is NOT passed via the session.prompt API — AGENTS.md
463+
// is the sole source of truth for the mayor's instructions.
464+
if (request.systemPrompt) {
465+
await writeMayorSystemPromptToAgentsMd(workdir, request.systemPrompt);
466+
}
377467
} else {
378468
// Resolve git credentials if missing. When the town config doesn't have
379469
// a token (common on first dispatch after rig creation), fetch one from
380470
// the Next.js server using the platform_integration_id.
381-
const envVars = await resolveGitCredentialsIfMissing(request);
471+
const envVars = await resolveGitCredentials(request);
472+
473+
// Merge resolved credentials back into the request so buildAgentEnv
474+
// can propagate GIT_TOKEN/GH_TOKEN to the spawned kilo serve process.
475+
// Without this, rigs using platformIntegrationId would clone successfully
476+
// but the agent session itself would lack git push / gh credentials.
477+
request = { ...request, envVars };
382478

383479
await cloneRepo({
384480
rigId: request.rigId,
@@ -390,6 +486,8 @@ export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent
390486
workdir = await createWorktree({
391487
rigId: request.rigId,
392488
branch: request.branch,
489+
startPoint: request.startPoint,
490+
defaultBranch: request.defaultBranch,
393491
});
394492

395493
// Set up git credentials so the agent can push
@@ -401,5 +499,13 @@ export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent
401499

402500
const env = buildAgentEnv(request);
403501

404-
return startAgent(request, workdir, env);
502+
// For the mayor, the system prompt lives in AGENTS.md (written above)
503+
// so all sessions — including sub-agents — share it. Don't also pass
504+
// it via the session.prompt API to avoid duplication. Setting to
505+
// undefined (not '') so the SDK omits it entirely and kilo serve
506+
// uses its default system prompt + AGENTS.md, rather than treating
507+
// an empty string as an explicit override.
508+
const startRequest = request.role === 'mayor' ? { ...request, systemPrompt: undefined } : request;
509+
510+
return startAgent(startRequest, workdir, env);
405511
}

cloudflare-gastown/container/src/control-server.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hono } from 'hono';
22
import { z } from 'zod';
3-
import { runAgent } from './agent-runner';
3+
import { runAgent, resolveGitCredentials } from './agent-runner';
44
import {
55
stopAgent,
66
sendMessage,
@@ -13,8 +13,8 @@ import {
1313
registerEventSink,
1414
} from './process-manager';
1515
import { startHeartbeat, stopHeartbeat } from './heartbeat';
16-
import { mergeBranch } from './git-manager';
17-
import { StartAgentRequest, SendMessageRequest, MergeRequest } from './types';
16+
import { mergeBranch, setupRigBrowseWorktree } from './git-manager';
17+
import { StartAgentRequest, SendMessageRequest, MergeRequest, SetupRepoRequest } from './types';
1818
import type {
1919
AgentStatusResponse,
2020
HealthResponse,
@@ -105,7 +105,7 @@ app.post('/agents/start', async c => {
105105
console.log(
106106
`[control-server] /agents/start: role=${parsed.data.role} name=${parsed.data.name} rigId=${parsed.data.rigId} agentId=${parsed.data.agentId}`
107107
);
108-
console.log(`[control-server] system prompt length: ${parsed.data.systemPrompt.length}`);
108+
console.log(`[control-server] system prompt length: ${parsed.data.systemPrompt?.length ?? 0}`);
109109

110110
try {
111111
const agent = await runAgent(parsed.data);
@@ -228,6 +228,53 @@ export function consumeStreamTicket(ticket: string): string | null {
228228
return entry.agentId;
229229
}
230230

231+
// POST /repos/setup
232+
// Proactively clone a rig's repo and create a browse worktree so the
233+
// mayor (and future agents) have immediate access to the codebase.
234+
// Called by the TownDO when a new rig is added.
235+
app.post('/repos/setup', async c => {
236+
const body: unknown = await c.req.json().catch(() => null);
237+
const parsed = SetupRepoRequest.safeParse(body);
238+
if (!parsed.success) {
239+
return c.json({ error: 'Invalid request body', issues: parsed.error.issues }, 400);
240+
}
241+
242+
const req = parsed.data;
243+
console.log(`[control-server] /repos/setup: rigId=${req.rigId} gitUrl=${req.gitUrl}`);
244+
245+
// Run in background so we return 202 immediately.
246+
// Errors are caught and logged — never propagated as unhandled rejections.
247+
const doSetup = async () => {
248+
try {
249+
// Resolve git credentials from platformIntegrationId if no token
250+
// is present in envVars (e.g. rigs using GitHub App installations).
251+
const envVars = await resolveGitCredentials({
252+
envVars: req.envVars,
253+
platformIntegrationId: req.platformIntegrationId,
254+
});
255+
256+
const browseDir = await setupRigBrowseWorktree({
257+
rigId: req.rigId,
258+
gitUrl: req.gitUrl,
259+
defaultBranch: req.defaultBranch,
260+
envVars,
261+
});
262+
console.log(`[control-server] /repos/setup: done rigId=${req.rigId} browse=${browseDir}`);
263+
} catch (err) {
264+
// Log as a warning, not an error — this is a best-effort background
265+
// operation. The mayor and agents can still function without the
266+
// browse worktree; it will be retried on the next agent dispatch.
267+
const message = err instanceof Error ? err.message : String(err);
268+
console.warn(
269+
`[control-server] /repos/setup: FAILED for rigId=${req.rigId}: ${message.split('\n')[0]}`
270+
);
271+
}
272+
};
273+
doSetup().catch(() => {});
274+
275+
return c.json({ status: 'accepted', message: 'Repo setup started' }, 202);
276+
});
277+
231278
// POST /git/merge
232279
// Deterministic merge of a polecat branch into the target branch.
233280
// Called by the Rig DO's processReviewQueue → startMergeInContainer.

0 commit comments

Comments
 (0)