From b8a7f875517be34f7af8df484ea664635405096c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:32:32 -0500 Subject: [PATCH 01/15] fix(gastown): remove persisted container env vars --- services/gastown/src/dos/Town.do.ts | 132 +----------------- services/gastown/src/dos/TownContainer.do.ts | 37 +---- .../src/dos/town/container-dispatch.ts | 49 ++----- 3 files changed, 22 insertions(+), 196 deletions(-) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index b96d5da0fc..afaa2f0e38 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -954,105 +954,17 @@ export class TownDO extends DurableObject { /** * Push config-derived env vars to the running container. Called after * updateTownConfig so that settings changes take effect without a - * container restart. New agent processes inherit the updated values. - * - * Two-phase push: - * 1. setEnvVar — persists to DO storage for next boot - * 2. POST /sync-config — hot-swaps process.env on the running container + * container restart. New agent processes receive current env through + * their start requests. */ async syncConfigToContainer(): Promise { const townId = this.townId; if (!townId) return; - const townConfig = await this.getTownConfig(); const container = getTownContainerStub(this.env, townId); - // Resolve a fresh GitHub token here too — this method runs both at - // initial config push and on every config change, so the persisted - // GIT_TOKEN must be live rather than the stale value stored in - // git_auth.github_token from rig creation. The container's - // syncTownConfigToProcessEnv path reads `git_auth.github_token` - // from the X-Town-Config header on every request, so the in-process - // GIT_TOKEN follows the same source-of-truth as the persisted one. - const githubToken = await scm.resolveGitHubTokenString({ - env: this.env, - townId, - getTownConfig: () => Promise.resolve(townConfig), - }); - - // Phase 1: Persist to DO storage for next boot. - const envMapping: Array<[string, string | undefined]> = [ - ['GIT_TOKEN', githubToken ?? undefined], - ['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], - ['KILOCODE_TOKEN', townConfig.kilocode_token], - ]; - - for (const [key, value] of envMapping) { - try { - if (value) { - await container.setEnvVar(key, value); - } else { - await container.deleteEnvVar(key); - } - } catch (err) { - console.warn(`[Town.do] syncConfigToContainer: ${key} sync failed:`, err); - } - } - - // Persist custom env_vars to DO storage so they survive container restarts. - // Compare against the previously-persisted set of keys to clear removed ones. - // Reserved infra keys are never overwritten or deleted — infra values always win. - const RESERVED_ENV_KEYS = new Set([ - 'KILOCODE_TOKEN', - 'GIT_TOKEN', - 'GITHUB_TOKEN', - 'GITLAB_TOKEN', - 'GITLAB_INSTANCE_URL', - 'GITHUB_CLI_PAT', - 'GH_TOKEN', - 'GASTOWN_GIT_AUTHOR_NAME', - 'GASTOWN_GIT_AUTHOR_EMAIL', - 'GASTOWN_DISABLE_AI_COAUTHOR', - 'GASTOWN_ORGANIZATION_ID', - 'GASTOWN_CONTAINER_TOKEN', - 'GASTOWN_SESSION_TOKEN', - 'GASTOWN_API_URL', - ]); - const CUSTOM_ENV_KEYS_STORAGE_KEY = 'container:custom_env_var_keys'; - const prevCustomKeys: string[] = - (await this.ctx.storage.get(CUSTOM_ENV_KEYS_STORAGE_KEY)) ?? []; - const newCustomKeys = Object.keys(townConfig.env_vars).filter( - key => !RESERVED_ENV_KEYS.has(key) - ); - const newCustomKeySet = new Set(newCustomKeys); - - for (const key of prevCustomKeys) { - if (RESERVED_ENV_KEYS.has(key)) continue; - if (!newCustomKeySet.has(key)) { - try { - await container.deleteEnvVar(key); - } catch (err) { - console.warn(`[Town.do] syncConfigToContainer: delete custom ${key} failed:`, err); - } - } - } - for (const [key, value] of Object.entries(townConfig.env_vars)) { - if (RESERVED_ENV_KEYS.has(key)) continue; - try { - await container.setEnvVar(key, value); - } catch (err) { - console.warn(`[Town.do] syncConfigToContainer: set custom ${key} failed:`, err); - } - } - await this.ctx.storage.put(CUSTOM_ENV_KEYS_STORAGE_KEY, newCustomKeys); - - // Phase 2: Push to the running container's process.env via the - // /sync-config endpoint. The X-Town-Config header delivers the - // full config; the endpoint applies CONFIG_ENV_MAP to process.env. + // Push to the running container's process.env via the /sync-config + // endpoint. The X-Town-Config header delivers the full config; the + // endpoint applies CONFIG_ENV_MAP to process.env. try { const containerConfig = await config.buildContainerConfig( this.ctx.storage, @@ -1162,19 +1074,6 @@ export class TownDO extends DurableObject { } } - const token = rigConfig.kilocodeToken ?? (await this.resolveKilocodeToken()); - if (token) { - try { - const container = getTownContainerStub(this.env, this.townId); - await container.setEnvVar('KILOCODE_TOKEN', token); - logger.info('configureRig: stored KILOCODE_TOKEN on TownContainerDO'); - } catch (err) { - logger.warn('configureRig: failed to store token on container DO', { - error: err instanceof Error ? err.message : String(err), - }); - } - } - logger.info('configureRig: proactively starting container'); await this.armAlarmIfNeeded(); try { @@ -2841,15 +2740,6 @@ export class TownDO extends DurableObject { orgId: townConfig.organization_id, }); - if (kilocodeToken) { - try { - const containerStub = getTownContainerStub(this.env, townId); - await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); - } catch { - // Best effort - } - } - const { started: mayorStarted } = await dispatch.startAgentInContainer( this.env, this.ctx.storage, @@ -3051,13 +2941,6 @@ export class TownDO extends DurableObject { label: 'fresh_dispatch', }); - try { - const containerStub = getTownContainerStub(this.env, townId); - 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: mayorStarted } = await dispatch.startAgentInContainer( @@ -4498,10 +4381,9 @@ export class TownDO extends DurableObject { } /** - * Push a fresh container-scoped JWT to the TownContainerDO. Called + * Push a fresh container-scoped JWT to the running control server. Called * from the alarm handler, throttled to once per hour (tokens have - * 8h expiry). The TownContainerDO stores it as an env var so it's - * available to all agents in the container. + * 8h expiry). New dispatches also pass fresh tokens in their request env. * * The throttle timestamp is persisted in ctx.storage so it survives * DO eviction. Without persistence, eviction resets the throttle to 0 diff --git a/services/gastown/src/dos/TownContainer.do.ts b/services/gastown/src/dos/TownContainer.do.ts index 2a29ea2504..fe594512cb 100644 --- a/services/gastown/src/dos/TownContainer.do.ts +++ b/services/gastown/src/dos/TownContainer.do.ts @@ -22,8 +22,8 @@ export class TownContainerDO extends Container { defaultPort = 8080; sleepAfter = '10m'; - // Container env vars. Includes infra URLs and any tokens stored via setEnvVar(). - // The Container base class reads this when booting the container. + // Static boot-time container env vars. Runtime town, rig, and agent + // configuration is sent through the control server request protocol. envVars: Record = { ...(this.env.GASTOWN_API_URL ? { GASTOWN_API_URL: this.env.GASTOWN_API_URL } : {}), ...(this.env.KILO_API_URL @@ -34,39 +34,6 @@ export class TownContainerDO extends Container { : {}), }; - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - // Load persisted env vars (like KILOCODE_TOKEN) into envVars - // so they're available when the container boots. - void ctx.blockConcurrencyWhile(async () => { - const stored = await ctx.storage.get>('container:envVars'); - if (stored) { - Object.assign(this.envVars, stored); - } - }); - } - - /** - * Store an env var that will be injected into the container OS environment. - * Takes effect on the next container boot (or immediately if the container - * hasn't started yet). Call this from the TownDO during configureRig. - */ - async setEnvVar(key: string, value: string): Promise { - const stored = (await this.ctx.storage.get>('container:envVars')) ?? {}; - stored[key] = value; - await this.ctx.storage.put('container:envVars', stored); - this.envVars[key] = value; - console.log(`${TC_LOG} setEnvVar: ${key} stored (${value.length} chars)`); - } - - async deleteEnvVar(key: string): Promise { - const stored = (await this.ctx.storage.get>('container:envVars')) ?? {}; - delete stored[key]; - await this.ctx.storage.put('container:envVars', stored); - delete this.envVars[key]; - console.log(`${TC_LOG} deleteEnvVar: ${key} removed`); - } - async updateRegistry(registry: unknown): Promise { await this.ctx.storage.put('container:registry', registry); console.log( diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index 0ef2f41da3..074008aa45 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -138,14 +138,14 @@ export async function mintAgentToken( } /** - * Mint a container-scoped JWT and push it to the TownContainerDO. + * Mint a container-scoped JWT and push it to the town container. * One JWT per container — shared by all agents in the town. Carries * { townId, userId, scope: 'container' } with 8h expiry. * - * Pushes via both setEnvVar() (for next container boot) and - * POST /refresh-token (for the running process). This ensures that - * all code paths — existing agents, heartbeat, event persistence — - * pick up the fresh token immediately. + * Pushes via POST /refresh-token when a container is running. New + * dispatches also include the fresh token in their request env. This + * ensures active code paths — existing agents, heartbeat, event + * persistence — pick up the fresh token immediately. * * Returns the token so callers can also pass it as a per-agent env var. */ @@ -163,17 +163,6 @@ export async function ensureContainerToken( const token = signContainerJWT({ townId, userId }, jwtSecret); const container = getTownContainerStub(env, townId); - // Store for next boot - try { - await container.setEnvVar('GASTOWN_CONTAINER_TOKEN', token); - await container.setEnvVar('GASTOWN_TOWN_ID', townId); - } catch (err) { - console.warn( - `${TOWN_LOG} ensureContainerToken: setEnvVar failed (container may not be running):`, - err instanceof Error ? err.message : err - ); - } - // Push to running process so existing agents pick up the fresh token. // Throw on non-2xx so the alarm's throttle doesn't advance on failure. try { @@ -187,9 +176,9 @@ export async function ensureContainerToken( throw new Error(`container returned ${resp.status}`); } } catch (err) { - // If the container isn't running yet, the token will be in envVars - // when it boots. But if it IS running and rejected the refresh, - // propagate the error so the alarm retries on the next tick. + // If the container isn't running yet, the next dispatch will include + // the token in request env. But if it IS running and rejected the + // refresh, propagate the error so the alarm retries on the next tick. const isContainerDown = err instanceof TypeError || (err instanceof Error && err.message.includes('fetch')); if (!isContainerDown) throw err; @@ -209,12 +198,11 @@ export const refreshContainerToken = ensureContainerToken; /** * Force-refresh variant for manual user-triggered refreshes. * - * Unlike ensureContainerToken (which tolerates a downed container - * because the token is persisted in envVars for next boot), this - * function throws on ANY failure to push the token to the running - * container — including network errors. This ensures the UI reports - * a real failure instead of a false success when the container - * never actually received the fresh JWT. + * Unlike ensureContainerToken (which tolerates a downed container because + * the next dispatch passes fresh request env), this function throws on ANY + * failure to push the token to the running container — including network + * errors. This ensures the UI reports a real failure instead of a false + * success when the container never actually received the fresh JWT. */ export async function forceRefreshContainerToken( env: Env, @@ -229,17 +217,6 @@ export async function forceRefreshContainerToken( const token = signContainerJWT({ townId, userId }, jwtSecret); const container = getTownContainerStub(env, townId); - // Store for next boot (best-effort — the critical step is the live push below) - try { - await container.setEnvVar('GASTOWN_CONTAINER_TOKEN', token); - await container.setEnvVar('GASTOWN_TOWN_ID', townId); - } catch (err) { - console.warn( - `${TOWN_LOG} forceRefreshContainerToken: setEnvVar failed:`, - err instanceof Error ? err.message : err - ); - } - // Push to running container — propagate ALL errors so the caller // (and ultimately the UI) knows the refresh didn't land. const resp = await container.fetch('http://container/refresh-token', { From aba5fcf3c3c37db69813e4f6a0909305ce8f5382 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:45:41 -0500 Subject: [PATCH 02/15] fix(gastown): hydrate container runtime credentials --- .../gastown/container/src/control-server.ts | 51 ++++++++++++++----- services/gastown/container/src/main.ts | 19 +++---- services/gastown/src/dos/Town.do.ts | 8 ++- services/gastown/src/dos/TownContainer.do.ts | 15 +++++- services/gastown/src/dos/town/config.ts | 1 + .../src/dos/town/container-dispatch.ts | 28 ++++++---- 6 files changed, 87 insertions(+), 35 deletions(-) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 749ebdd7b8..8eb7b559ea 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -42,6 +42,11 @@ import { classifyStartupError } from './startup-error'; const MAX_TICKETS = 1000; const streamTickets = new Map(); +const RefreshTokenRequest = z.object({ + token: z.string().min(1), + townId: z.string().optional(), +}); + // Minimal Zod schema for the town config delivered via X-Town-Config header. // Uses z.record() so any string-keyed object is accepted and future keys are preserved. const TownConfigHeader = z.record(z.string(), z.unknown()); @@ -96,6 +101,8 @@ function syncTownConfigToProcessEnv(): void { if (!cfg) return; const CONFIG_ENV_MAP: Array<[string, string]> = [ + ['town_id', 'GASTOWN_TOWN_ID'], + ['gastown_api_url', 'GASTOWN_API_URL'], ['github_cli_pat', 'GITHUB_CLI_PAT'], ['git_author_name', 'GASTOWN_GIT_AUTHOR_NAME'], ['git_author_email', 'GASTOWN_GIT_AUTHOR_EMAIL'], @@ -177,6 +184,7 @@ app.use('*', async (c, next) => { const result = TownConfigHeader.safeParse(raw); if (result.success) { lastKnownTownConfig = result.data; + syncTownConfigToProcessEnv(); const hasToken = typeof result.data.kilocode_token === 'string' && result.data.kilocode_token.length > 0; console.log( @@ -261,11 +269,12 @@ app.post('/dashboard-context', async c => { // server — matching the model hot-swap path. app.post('/refresh-token', async c => { const body: unknown = await c.req.json().catch(() => null); - if (!body || typeof body !== 'object' || !('token' in body) || typeof body.token !== 'string') { - return c.json({ error: 'Missing or invalid token field' }, 400); + const parsed = RefreshTokenRequest.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'Invalid request body', issues: parsed.error.issues }, 400); } // Capture the new token into a local so it survives the await below. - const newToken = body.token; + const newToken = parsed.data.token; // Wait for boot hydration to release the global sdkServerLock before // we mutate process.env or serialise N agent restarts through it. @@ -277,6 +286,9 @@ app.post('/refresh-token', async c => { // Now safe to assign: hydration is done, no concurrent env readers. process.env.GASTOWN_CONTAINER_TOKEN = newToken; + if (parsed.data.townId) { + process.env.GASTOWN_TOWN_ID = parsed.data.townId; + } const activeAgents = listAgents().filter(a => a.status === 'running' || a.status === 'starting'); log.info('refresh_token.received', { @@ -304,9 +316,8 @@ app.post('/refresh-token', async c => { // POST /sync-config // Push config-derived env vars from X-Town-Config into process.env on -// the running container. Called by TownDO.syncConfigToContainer() after -// persisting env vars to DO storage, so the live process picks up -// changes (e.g. refreshed KILOCODE_TOKEN) without a container restart. +// the running container, so the live process picks up changes (e.g. +// refreshed KILOCODE_TOKEN) without a container restart. app.post('/sync-config', async c => { syncTownConfigToProcessEnv(); return c.json({ synced: true }); @@ -339,6 +350,10 @@ app.post('/agents/start', async c => { // config rebuilds (e.g. model hot-swap). The env var is the primary // source of truth; KILO_CONFIG_CONTENT extraction is the fallback. process.env.GASTOWN_ORGANIZATION_ID = parsed.data.organizationId ?? ''; + process.env.GASTOWN_TOWN_ID = parsed.data.townId; + if (parsed.data.envVars?.GASTOWN_CONTAINER_TOKEN) { + process.env.GASTOWN_CONTAINER_TOKEN = parsed.data.envVars.GASTOWN_CONTAINER_TOKEN; + } console.log( `[control-server] /agents/start: role=${parsed.data.role} name=${parsed.data.name} rigId=${parsed.data.rigId} agentId=${parsed.data.agentId}` @@ -680,10 +695,14 @@ app.post('/git/merge', async c => { // Called by the process-manager when the agent goes idle. app.get('/agents/:agentId/pending-nudges', async c => { const { agentId } = c.req.param(); - const apiUrl = process.env.GASTOWN_API_URL; - const token = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN; - const townId = process.env.GASTOWN_TOWN_ID; - const rigId = process.env.GASTOWN_RIG_ID; + const agent = getAgentStatus(agentId); + const apiUrl = agent?.gastownApiUrl ?? process.env.GASTOWN_API_URL; + const token = + process.env.GASTOWN_CONTAINER_TOKEN ?? + agent?.gastownContainerToken ?? + agent?.gastownSessionToken; + const townId = agent?.townId ?? process.env.GASTOWN_TOWN_ID; + const rigId = agent?.rigId; if (!apiUrl || !token || !townId || !rigId) { return c.json({ error: 'Missing gastown configuration' }, 503); @@ -713,10 +732,14 @@ app.get('/agents/:agentId/pending-nudges', async c => { // Body: { nudge_id: string } app.post('/agents/:agentId/nudge-delivered', async c => { const { agentId } = c.req.param(); - const apiUrl = process.env.GASTOWN_API_URL; - const token = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN; - const townId = process.env.GASTOWN_TOWN_ID; - const rigId = process.env.GASTOWN_RIG_ID; + const agent = getAgentStatus(agentId); + const apiUrl = agent?.gastownApiUrl ?? process.env.GASTOWN_API_URL; + const token = + process.env.GASTOWN_CONTAINER_TOKEN ?? + agent?.gastownContainerToken ?? + agent?.gastownSessionToken; + const townId = agent?.townId ?? process.env.GASTOWN_TOWN_ID; + const rigId = agent?.rigId; if (!apiUrl || !token || !townId || !rigId) { return c.json({ error: 'Missing gastown configuration' }, 503); diff --git a/services/gastown/container/src/main.ts b/services/gastown/container/src/main.ts index 4abff0666e..50bcb34b60 100644 --- a/services/gastown/container/src/main.ts +++ b/services/gastown/container/src/main.ts @@ -2,16 +2,17 @@ import { startControlServer } from './control-server'; import { log } from './logger'; import { activeAgentCount, bootHydration, getUptime, listAgents } from './process-manager'; -// Container-scoped identifiers for crash/diagnostic logs. The container is -// pinned to a single town for its lifetime (see GASTOWN_TOWN_ID injection in -// the deployer), so reading these once at module init is safe and lets us -// emit them even when no agents are registered yet. -const TOWN_ID = process.env.GASTOWN_TOWN_ID ?? null; +// Container-scoped identifier for crash/diagnostic logs. It can arrive at +// boot via container start options or shortly after via the first control +// request, so read it when logging instead of capturing module-init state. +function townIdForLogs(): string | null { + return process.env.GASTOWN_TOWN_ID ?? null; +} log.info('container.cold_start', { uptime: getUptime(), ts: new Date().toISOString(), - townId: TOWN_ID, + townId: townIdForLogs(), }); // Bun (like Node) will ignore unhandled promise rejections unless a handler @@ -30,7 +31,7 @@ process.on('unhandledRejection', reason => { : { message: String(reason) }; log.error('container.unhandled_rejection', { ...err, - townId: TOWN_ID, + townId: townIdForLogs(), uptimeMs: getUptime(), activeAgents: activeAgentCount(), }); @@ -41,7 +42,7 @@ process.on('uncaughtException', err => { message: err.message, stack: err.stack, name: err.name, - townId: TOWN_ID, + townId: townIdForLogs(), uptimeMs: getUptime(), activeAgents: activeAgentCount(), }); @@ -68,7 +69,7 @@ setInterval(() => { heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024), heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024), externalMB: Math.round(mem.external / 1024 / 1024), - townId: TOWN_ID, + townId: townIdForLogs(), uptimeMs: getUptime(), agents: listAgents().length, activeAgents: activeAgentCount(), diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index afaa2f0e38..cbb727e00a 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -4880,7 +4880,13 @@ export class TownDO extends DurableObject { // actually starts the container. Already-running containers still rely // on the /health probe below as the application-level liveness source. try { - const warm = await container.warmUp(); + const townConfig = await this.getTownConfig(); + const userId = townConfig.owner_user_id ?? townConfig.created_by_user_id ?? townId; + const containerToken = await dispatch.mintContainerToken(this.env, { townId, userId }); + const warm = await container.warmUp({ + townId, + ...(containerToken ? { containerToken } : {}), + }); if (warm.coldStart) { writeEvent(this.env, { event: 'container.cold_start', diff --git a/services/gastown/src/dos/TownContainer.do.ts b/services/gastown/src/dos/TownContainer.do.ts index fe594512cb..9221f00f41 100644 --- a/services/gastown/src/dos/TownContainer.do.ts +++ b/services/gastown/src/dos/TownContainer.do.ts @@ -58,7 +58,10 @@ export class TownContainerDO extends Container { * Returns how long startAndWaitForPorts took when this call actually * triggered a cold start. */ - async warmUp(): Promise<{ coldStart: boolean; durationMs: number }> { + async warmUp(params: { + townId: string; + containerToken?: string; + }): Promise<{ coldStart: boolean; durationMs: number }> { if (this.ctx.container?.running === true) { // Runtime-level fast path only. TownDO's /health probe is the // application-level liveness source and may still recover a wedged @@ -66,7 +69,15 @@ export class TownContainerDO extends Container { return { coldStart: false, durationMs: 0 }; } const t0 = Date.now(); - await this.startAndWaitForPorts(); + await this.startAndWaitForPorts({ + startOptions: { + envVars: { + ...this.envVars, + GASTOWN_TOWN_ID: params.townId, + ...(params.containerToken ? { GASTOWN_CONTAINER_TOKEN: params.containerToken } : {}), + }, + }, + }); return { coldStart: true, durationMs: Date.now() - t0 }; } diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 85b3ca4715..8262b8ea1f 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -330,6 +330,7 @@ export async function buildContainerConfig( } return { + town_id: townId, env_vars: config.env_vars, default_model: resolveModel(config, null, ''), small_model: resolveSmallModel(config), diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index 074008aa45..d112dbeea8 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -137,6 +137,19 @@ export async function mintAgentToken( ); } +export async function mintContainerToken( + env: Env, + params: { townId: string; userId: string } +): Promise { + const jwtSecret = await resolveJWTSecret(env); + if (!jwtSecret) { + console.error(`${TOWN_LOG} mintContainerToken: no JWT secret available`); + return null; + } + + return signContainerJWT({ townId: params.townId, userId: params.userId }, jwtSecret); +} + /** * Mint a container-scoped JWT and push it to the town container. * One JWT per container — shared by all agents in the town. Carries @@ -154,13 +167,11 @@ export async function ensureContainerToken( townId: string, userId: string ): Promise { - const jwtSecret = await resolveJWTSecret(env); - if (!jwtSecret) { - console.error(`${TOWN_LOG} ensureContainerToken: no JWT secret available`); + const token = await mintContainerToken(env, { townId, userId }); + if (!token) { return null; } - const token = signContainerJWT({ townId, userId }, jwtSecret); const container = getTownContainerStub(env, townId); // Push to running process so existing agents pick up the fresh token. @@ -170,7 +181,7 @@ export async function ensureContainerToken( method: 'POST', signal: AbortSignal.timeout(10_000), headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), + body: JSON.stringify({ token, townId }), }); if (!resp.ok) { throw new Error(`container returned ${resp.status}`); @@ -209,12 +220,11 @@ export async function forceRefreshContainerToken( townId: string, userId: string ): Promise { - const jwtSecret = await resolveJWTSecret(env); - if (!jwtSecret) { + const token = await mintContainerToken(env, { townId, userId }); + if (!token) { throw new Error('No JWT secret available — cannot mint container token'); } - const token = signContainerJWT({ townId, userId }, jwtSecret); const container = getTownContainerStub(env, townId); // Push to running container — propagate ALL errors so the caller @@ -222,7 +232,7 @@ export async function forceRefreshContainerToken( const resp = await container.fetch('http://container/refresh-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), + body: JSON.stringify({ token, townId }), }); if (!resp.ok) { const body = await resp.text().catch(() => ''); From 9a1e166f12888bb360e2809fba0a7a8b2943cfe3 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:46:20 -0500 Subject: [PATCH 03/15] fix(gastown): use dynamic town id in boot logs --- services/gastown/container/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/gastown/container/src/main.ts b/services/gastown/container/src/main.ts index 50bcb34b60..07b0ecb230 100644 --- a/services/gastown/container/src/main.ts +++ b/services/gastown/container/src/main.ts @@ -94,7 +94,7 @@ void (async () => { log.error('container.boot_hydration_failed', { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, - townId: TOWN_ID, + townId: townIdForLogs(), }); } })(); From 9840aa597b143eeef3743999a5e746f629520a27 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 20 May 2026 13:00:04 -0500 Subject: [PATCH 04/15] fix(gastown): bump @kilocode/cli to 7.3.1 + plumb auth env into prewarm (#3372) Bug 1: @kilocode/cli@7.2.14 doesn't read KILO_AUTH_CONTENT, causing all kilo serve session-ingest to silently no-op. Bumped to 7.3.1 which has the feature. Verified KILO_AUTH_CONTENT present in binary strings. Bug 2: buildPrewarmEnv didn't set KILO_AUTH_CONTENT, KILO_PLATFORM, or KILO_ORG_ID, so mayor sessions (which go through prewarm) were invisible. Extracted buildKiloAuthEnv helper from buildAgentEnv and used it in both buildAgentEnv and buildPrewarmEnv. Refs #3307 Co-authored-by: John Fawcett --- pnpm-lock.yaml | 18 ++++---- services/gastown/container/Dockerfile | 2 +- services/gastown/container/Dockerfile.dev | 2 +- services/gastown/container/package.json | 2 +- .../container/src/agent-runner.test.ts | 43 ++++++++++++++++++- .../gastown/container/src/agent-runner.ts | 37 ++++++++-------- .../container/src/process-manager.test.ts | 24 +++++++++++ .../gastown/container/src/process-manager.ts | 3 ++ 8 files changed, 98 insertions(+), 33 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94de1a80ce..a5bb8fa091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2232,8 +2232,8 @@ importers: specifier: 7.2.52 version: 7.2.52 '@kilocode/sdk': - specifier: 7.2.14 - version: 7.2.14 + specifier: 7.3.1 + version: 7.3.1 hono: specifier: 4.12.25 version: 4.12.25 @@ -4477,11 +4477,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} @@ -5346,12 +5346,12 @@ packages: '@opentui/solid': optional: true - '@kilocode/sdk@7.2.14': - resolution: {integrity: sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==} - '@kilocode/sdk@7.2.52': resolution: {integrity: sha512-j8w6ewvo7dyu/qxjJAg0bcjHGUGGvIZ4F2f5tJnpMwLzPTAu26DJoO/08aoxf1BhfuZLzNS9tA2q+ZPdzPT8Jg==} + '@kilocode/sdk@7.3.1': + resolution: {integrity: sha512-UFsCx+Nman7J0jBTr1mZxt6IQkpxkxt3Lqa+gb7/1lSjo0psRgO61H9sUfgLDvAFeaJsQy7k7KMq1eigsbd4rQ==} + '@kilocode/sdk@7.3.54': resolution: {integrity: sha512-A+g744DVuAHXSU3pu6BLe2AoxEh4fq7NdrYvlET3+waXSS0tG41Blr3ItCKSMLJH5M6C4rK2ZXi7plzkPNbdmA==} @@ -21355,11 +21355,11 @@ snapshots: effect: 4.0.0-beta.57 zod: 4.1.8 - '@kilocode/sdk@7.2.14': + '@kilocode/sdk@7.2.52': dependencies: cross-spawn: 7.0.6 - '@kilocode/sdk@7.2.52': + '@kilocode/sdk@7.3.1': dependencies: cross-spawn: 7.0.6 diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index 5dbdc5057e..511d4a13c1 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -77,7 +77,7 @@ RUN apt-get update && \ # Install both glibc and musl variants — the CLI's binary resolver may # pick either depending on the detected libc. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli@7.2.14 @kilocode/cli-linux-x64@7.2.14 @kilocode/cli-linux-x64-musl@7.2.14 @kilocode/plugin@7.2.14 pnpm && \ +RUN npm install -g @kilocode/cli@7.3.1 @kilocode/cli-linux-x64@7.3.1 @kilocode/cli-linux-x64-musl@7.3.1 @kilocode/plugin@7.3.1 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth diff --git a/services/gastown/container/Dockerfile.dev b/services/gastown/container/Dockerfile.dev index 8ed99f9724..e767c66a14 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -76,7 +76,7 @@ RUN apt-get update && \ # pick either depending on the detected libc. bun:1-slim is Debian (glibc) # but the resolver sometimes misdetects; installing both is safe. # Also install pnpm — many projects use it as their package manager. -RUN npm install -g @kilocode/cli@7.2.14 @kilocode/cli-linux-arm64@7.2.14 @kilocode/cli-linux-arm64-musl@7.2.14 @kilocode/plugin@7.2.14 pnpm && \ +RUN npm install -g @kilocode/cli@7.3.1 @kilocode/cli-linux-arm64@7.3.1 @kilocode/cli-linux-arm64-musl@7.3.1 @kilocode/plugin@7.3.1 pnpm && \ ln -s "$(which kilo)" /usr/local/bin/opencode # Create non-root user for defense-in-depth diff --git a/services/gastown/container/package.json b/services/gastown/container/package.json index d413c6091d..b2508545d2 100644 --- a/services/gastown/container/package.json +++ b/services/gastown/container/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@kilocode/plugin": "7.2.52", - "@kilocode/sdk": "7.2.14", + "@kilocode/sdk": "7.3.1", "hono": "catalog:", "zod": "catalog:" }, diff --git a/services/gastown/container/src/agent-runner.test.ts b/services/gastown/container/src/agent-runner.test.ts index da33166d16..7123c1d7e2 100644 --- a/services/gastown/container/src/agent-runner.test.ts +++ b/services/gastown/container/src/agent-runner.test.ts @@ -18,7 +18,7 @@ vi.mock('./logger', () => ({ log: { info: vi.fn() }, })); -import { buildAgentEnv, buildKiloConfigContent } from './agent-runner'; +import { buildAgentEnv, buildKiloAuthEnv, buildKiloConfigContent } from './agent-runner'; import type { StartAgentRequest } from './types'; function baseRequest(overrides: Partial = {}): StartAgentRequest { @@ -117,3 +117,44 @@ describe('buildKiloConfigContent', () => { expect(parsed.provider.kilo.options.kilocodeOrganizationId).toBeUndefined(); }); }); + +describe('buildKiloAuthEnv', () => { + it('sets KILO_PLATFORM to gastown', () => { + const env = buildKiloAuthEnv(undefined, undefined); + expect(env.KILO_PLATFORM).toBe('gastown'); + }); + + it('sets KILO_AUTH_CONTENT when kilocodeToken is provided', () => { + const env = buildKiloAuthEnv('tok-abc', undefined); + expect(env.KILO_AUTH_CONTENT).toBe(JSON.stringify({ kilo: { type: 'api', key: 'tok-abc' } })); + }); + + it('omits KILO_AUTH_CONTENT when kilocodeToken is absent', () => { + const env = buildKiloAuthEnv(undefined, undefined); + expect(env.KILO_AUTH_CONTENT).toBeUndefined(); + }); + + it('sets KILO_ORG_ID when organizationId is provided', () => { + const env = buildKiloAuthEnv('tok', 'org-123'); + expect(env.KILO_ORG_ID).toBe('org-123'); + }); + + it('omits KILO_ORG_ID when organizationId is null', () => { + const env = buildKiloAuthEnv('tok', null); + expect(env.KILO_ORG_ID).toBeUndefined(); + }); + + it('omits KILO_ORG_ID when organizationId is undefined', () => { + const env = buildKiloAuthEnv('tok', undefined); + expect(env.KILO_ORG_ID).toBeUndefined(); + }); + + it('sets all three env vars when both inputs are provided', () => { + const env = buildKiloAuthEnv('tok-full', 'org-full'); + expect(env).toEqual({ + KILO_PLATFORM: 'gastown', + KILO_AUTH_CONTENT: JSON.stringify({ kilo: { type: 'api', key: 'tok-full' } }), + KILO_ORG_ID: 'org-full', + }); + }); +}); diff --git a/services/gastown/container/src/agent-runner.ts b/services/gastown/container/src/agent-runner.ts index 30ac6330ca..404ee38559 100644 --- a/services/gastown/container/src/agent-runner.ts +++ b/services/gastown/container/src/agent-runner.ts @@ -85,6 +85,22 @@ export function buildKiloConfigContent( } satisfies Config); } +export function buildKiloAuthEnv( + kilocodeToken: string | undefined, + organizationId: string | undefined | null +): Record { + const env: Record = { + KILO_PLATFORM: 'gastown', + }; + if (kilocodeToken) { + env.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: 'api', key: kilocodeToken } }); + } + if (organizationId) { + env.KILO_ORG_ID = organizationId; + } + return env; +} + export function buildAgentEnv(request: StartAgentRequest): Record { // Custom git identity: when GASTOWN_GIT_AUTHOR_NAME is set, the user becomes // the primary author and the AI agent name is used for co-authorship trailers. @@ -173,30 +189,11 @@ GASTOWN_TOWN_ID="${env.GASTOWN_TOWN_ID}"`); console.log( `[buildAgentEnv] KILO_CONFIG_CONTENT set (model=${request.model}, smallModel=${request.smallModel ?? '(default)'})` ); - - // Set KILO_AUTH_CONTENT so the kilo CLI's session-ingest path can - // authenticate. The CLI's Auth.all() reads this env var before - // falling back to the auth.json file. Without it, session deltas - // get "session bootstrap skipped: no client" and never reach - // cli_sessions_v2. - env.KILO_AUTH_CONTENT = JSON.stringify({ - kilo: { type: 'api', key: kilocodeToken }, - }); } else { console.warn('[buildAgentEnv] No KILOCODE_TOKEN available — KILO_CONFIG_CONTENT not set'); } - // Set KILO_PLATFORM so session-ingest writes created_on_platform = - // 'gastown'. The /cloud/sessions page has a "Gastown" filter that - // matches this value. - env.KILO_PLATFORM = 'gastown'; - - // Set KILO_ORG_ID so session-ingest populates organization_id for - // org-scoped filtering. Falls back to the auth file's accountId - // inside the CLI if not set. - if (request.organizationId) { - env.KILO_ORG_ID = request.organizationId; - } + Object.assign(env, buildKiloAuthEnv(kilocodeToken, request.organizationId)); // Authenticate the gh CLI via GH_TOKEN. Prefer the user's GitHub CLI PAT // (which makes PRs/issues appear under their identity) over the integration diff --git a/services/gastown/container/src/process-manager.test.ts b/services/gastown/container/src/process-manager.test.ts index 1afd68112c..1e06e7c193 100644 --- a/services/gastown/container/src/process-manager.test.ts +++ b/services/gastown/container/src/process-manager.test.ts @@ -14,6 +14,18 @@ vi.mock('./agent-runner', () => ({ (kilocodeToken: string, model: string, smallModel: string, organizationId?: string) => JSON.stringify({ kilocodeToken, model, smallModel, organizationId }) ), + buildKiloAuthEnv: vi.fn( + (kilocodeToken?: string, organizationId?: string | null) => { + const authEnv: Record = { KILO_PLATFORM: 'gastown' }; + if (kilocodeToken) { + authEnv.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: 'api', key: kilocodeToken } }); + } + if (organizationId) { + authEnv.KILO_ORG_ID = organizationId; + } + return authEnv; + } + ), resolveGitCredentials: vi.fn(), writeMayorSystemPromptToAgentsMd: vi.fn(), ensureMayorWorkspaceForTown: vi.fn(async (_townId: string) => TEST_WORKSPACE), @@ -214,10 +226,12 @@ describe('awaitHydration', () => { apiUrl: process.env.GASTOWN_API_URL, townId: process.env.GASTOWN_TOWN_ID, token: process.env.GASTOWN_CONTAINER_TOKEN, + kiloOrgId: process.env.KILO_ORG_ID, }; process.env.GASTOWN_API_URL = 'http://test.invalid'; process.env.GASTOWN_TOWN_ID = 'town-prewarm'; process.env.GASTOWN_CONTAINER_TOKEN = 'tok-prewarm'; + delete process.env.KILO_ORG_ID; let capturedEnv: Record | null = null; createKilo.mockImplementationOnce(() => { @@ -229,6 +243,9 @@ describe('awaitHydration', () => { GASTOWN_API_URL: process.env.GASTOWN_API_URL, GASTOWN_CONTAINER_TOKEN: process.env.GASTOWN_CONTAINER_TOKEN, KILO_CONFIG_CONTENT: process.env.KILO_CONFIG_CONTENT, + KILO_AUTH_CONTENT: process.env.KILO_AUTH_CONTENT, + KILO_PLATFORM: process.env.KILO_PLATFORM, + KILO_ORG_ID: process.env.KILO_ORG_ID, }; return Promise.resolve({ client: {} as unknown, @@ -270,6 +287,11 @@ describe('awaitHydration', () => { GASTOWN_CONTAINER_TOKEN: 'tok-prewarm', }); expect(env?.KILO_CONFIG_CONTENT).toBeTruthy(); + expect(env?.KILO_PLATFORM).toBe('gastown'); + expect(env?.KILO_AUTH_CONTENT).toBe( + JSON.stringify({ kilo: { type: 'api', key: 'kc-tok' } }) + ); + expect(env?.KILO_ORG_ID).toBeUndefined(); } finally { globalThis.fetch = originalFetch; if (prev.apiUrl !== undefined) process.env.GASTOWN_API_URL = prev.apiUrl; @@ -278,6 +300,8 @@ describe('awaitHydration', () => { else delete process.env.GASTOWN_TOWN_ID; if (prev.token !== undefined) process.env.GASTOWN_CONTAINER_TOKEN = prev.token; else delete process.env.GASTOWN_CONTAINER_TOKEN; + if (prev.kiloOrgId !== undefined) process.env.KILO_ORG_ID = prev.kiloOrgId; + else delete process.env.KILO_ORG_ID; } }); diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index c0e243d92a..86ed72e5d1 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -13,6 +13,7 @@ import type { ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; import { buildKiloConfigContent, + buildKiloAuthEnv, ensureMayorWorkspaceForTown, mayorWorkdirForTown, } from './agent-runner'; @@ -2772,6 +2773,8 @@ function buildPrewarmEnv(ctx: MayorPrewarmContext, townId: string): Record Date: Wed, 20 May 2026 13:32:08 -0500 Subject: [PATCH 05/15] perf(gastown): shorten mayor cold start path (#3369) * perf(gastown): shorten mayor cold start path * fix(gastown): address mayor latency review feedback * fix(gastown): remove stale mayor setup parameter * fix(gastown): remove stale mayor setup comment --- .../gastown/container/src/agent-runner.ts | 125 ++++++++++------ .../gastown/container/src/process-manager.ts | 141 +++++++++++++----- .../src/dos/town/container-dispatch.ts | 46 +++++- services/gastown/src/trpc/router.ts | 67 ++++++--- 4 files changed, 272 insertions(+), 107 deletions(-) diff --git a/services/gastown/container/src/agent-runner.ts b/services/gastown/container/src/agent-runner.ts index 404ee38559..17d226f1a8 100644 --- a/services/gastown/container/src/agent-runner.ts +++ b/services/gastown/container/src/agent-runner.ts @@ -515,58 +515,21 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise { - const envVars = await resolveGitCredentials({ - envVars: baseEnvVars, - platformIntegrationId: rig.platformIntegrationId, - }); - const hasGitToken = !!(envVars.GIT_TOKEN || envVars.GITHUB_TOKEN || envVars.GITLAB_TOKEN); - console.log( - `[runAgent] setting up browse worktree: rig=${rig.rigId} gitUrl=${rig.gitUrl} hasGitToken=${hasGitToken}` - ); - await setupRigBrowseWorktree({ - rigId: rig.rigId, - gitUrl: rig.gitUrl, - defaultBranch: rig.defaultBranch, - envVars, - }); - return rig.rigId; - }) - ); - - const failures: Array<{ rigId: string; error: unknown }> = []; - for (let i = 0; i < rigSetupResults.length; i++) { - const r = rigSetupResults[i]; - if (r.status === 'rejected') { - const reason: unknown = r.reason; - failures.push({ rigId: request.rigs[i].rigId, error: reason }); - } - } - - if (failures.length > 0) { - for (const f of failures) { - const msg = f.error instanceof Error ? f.error.message : String(f.error); - const stack = f.error instanceof Error ? f.error.stack : undefined; - console.error( - `[runAgent] browse worktree setup FAILED for rig=${f.rigId}: ${msg}`, - stack ? `\n${stack}` : '' - ); - } - console.error( - `[runAgent] mayor rig setup: ${failures.length}/${request.rigs.length} rigs failed. ` + - `Mayor will start but may not be able to browse these codebases.` - ); - } + void setupMayorBrowseWorktrees(request).catch(err => { + console.error('[runAgent] background mayor browse worktree setup failed:', err); + }); } // Write the system prompt to AGENTS.md so the mayor AND its built-in @@ -574,7 +537,13 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise { + if (!request.rigs?.length) return; + + const setupStart = Date.now(); + const baseEnvVars = request.envVars ?? {}; + const rigSetupResults = await Promise.allSettled( + request.rigs.map(async rig => { + const envVars = await resolveGitCredentials({ + envVars: baseEnvVars, + platformIntegrationId: rig.platformIntegrationId, + }); + const hasGitToken = !!(envVars.GIT_TOKEN || envVars.GITHUB_TOKEN || envVars.GITLAB_TOKEN); + console.log( + `[runAgent] setting up browse worktree: rig=${rig.rigId} gitUrl=${rig.gitUrl} hasGitToken=${hasGitToken}` + ); + await setupRigBrowseWorktree({ + rigId: rig.rigId, + gitUrl: rig.gitUrl, + defaultBranch: rig.defaultBranch, + envVars, + }); + return rig.rigId; + }) + ); + + const failures: Array<{ rigId: string; error: unknown }> = []; + for (let i = 0; i < rigSetupResults.length; i++) { + const result = rigSetupResults[i]; + if (result.status === 'rejected') { + failures.push({ rigId: request.rigs[i].rigId, error: result.reason }); + } + } + + if (failures.length > 0) { + for (const failure of failures) { + const msg = failure.error instanceof Error ? failure.error.message : String(failure.error); + const stack = failure.error instanceof Error ? failure.error.stack : undefined; + console.error( + `[runAgent] browse worktree setup FAILED for rig=${failure.rigId}: ${msg}`, + stack ? `\n${stack}` : '' + ); + } + console.error( + `[runAgent] mayor rig setup: ${failures.length}/${request.rigs.length} rigs failed. ` + + `Mayor will start but may not be able to browse these codebases.` + ); + } + + const setupDurationMs = Date.now() - setupStart; + log.info('mayor.browse_worktree_setup_ms', { + agentId: request.agentId, + townId: request.townId, + durationMs: setupDurationMs, + rigCount: request.rigs.length, + failureCount: failures.length, + }); +} diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 86ed72e5d1..ef8a51bf35 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -9,7 +9,7 @@ import { createKilo, type KiloClient } from '@kilocode/sdk'; import { z } from 'zod'; import * as fs from 'node:fs/promises'; -import type { ManagedAgent, StartAgentRequest } from './types'; +import { type ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; import { buildKiloConfigContent, @@ -32,6 +32,15 @@ const MANAGER_LOG = '[process-manager]'; // if the SDK changes its return type. const SessionResponse = z.object({ id: z.string().min(1) }).passthrough(); +const ContainerRegistryResponse = z.object({ data: z.unknown() }).passthrough(); +const ContainerRegistryEntry = z.object({ + agentId: z.string().min(1), + request: StartAgentRequest, + workdir: z.string().min(1), + env: z.record(z.string(), z.string()), +}); +type ContainerRegistryEntry = z.infer; + type SDKInstance = { client: KiloClient; server: { url: string; close(): void }; @@ -2909,67 +2918,121 @@ async function bootHydrationImpl(LOG: string): Promise { console.log(`${LOG} Fetching container registry for town=${townId}`); let registry: unknown; + const registryFetchStart = Date.now(); try { const resp = await fetch(`${apiUrl}/api/towns/${townId}/container-registry`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10_000), }); + const registryFetchMs = Date.now() - registryFetchStart; + log.info('bootHydration.registry_fetch_ms', { + townId, + durationMs: registryFetchMs, + statusCode: resp.status, + }); + postEventToWorker('bootHydration.registry_fetch_ms', { durationMs: registryFetchMs }); if (!resp.ok) { console.warn(`${LOG} Failed to fetch registry: ${resp.status}`); return; } - const json = (await resp.json()) as { data: unknown }; - registry = json.data; + registry = ContainerRegistryResponse.parse(await resp.json()).data; } catch (err) { + const registryFetchMs = Date.now() - registryFetchStart; + log.warn('bootHydration.registry_fetch_ms', { + townId, + durationMs: registryFetchMs, + error: err instanceof Error ? err.message : String(err), + }); + postEventToWorker('bootHydration.registry_fetch_ms', { + durationMs: registryFetchMs, + error: err instanceof Error ? err.message : String(err), + }); console.warn(`${LOG} Registry fetch failed:`, err); return; } - if (!Array.isArray(registry) || registry.length === 0) { + const registryEntries = parseRegistryEntries(LOG, registry); + if (registryEntries.length === 0) { console.log(`${LOG} No agents in registry — nothing to hydrate`); } else { - console.log(`${LOG} Resuming ${registry.length} agent(s) from registry`); - - for (const entry of registry as Record[]) { - const agentId = entry.agentId as string | undefined; - const agentRequest = entry.request as StartAgentRequest | undefined; - const workdir = entry.workdir as string | undefined; - const env = entry.env as Record | undefined; - - if (!agentId || !agentRequest || !workdir || !env) { - console.warn(`${LOG} Skipping malformed registry entry:`, entry); - continue; - } + console.log(`${LOG} Resuming ${registryEntries.length} agent(s) from registry`); + } - // Registry entries were written with the token snapshot at dispatch - // time. If we just refreshed, overlay the fresh value so the hydrated - // kilo serve child inherits the current token. - const hydratedEnv = { ...env, GASTOWN_CONTAINER_TOKEN: token }; + const mayorEntries = registryEntries.filter(entry => entry.request.role === 'mayor'); + const nonMayorEntries = registryEntries.filter(entry => !mayorEntries.includes(entry)); - console.log(`${LOG} Resuming agent ${agentId} in ${workdir}`); - try { - await startAgent(agentRequest, workdir, hydratedEnv); - console.log(`${LOG} Agent ${agentId} resumed`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`${LOG} Failed to resume agent ${agentId}:`, msg); - } + if (mayorEntries.length > 0) { + const mayorResumeStart = Date.now(); + await resumeRegistryEntries(LOG, mayorEntries, token); + const mayorResumeMs = Date.now() - mayorResumeStart; + log.info('bootHydration.mayor_resume_ms', { townId, durationMs: mayorResumeMs }); + postEventToWorker('bootHydration.mayor_resume_ms', { durationMs: mayorResumeMs }); + } else { + const mayorPrewarmStart = Date.now(); + try { + await prewarmMayorSDK(townId, apiUrl, token); + } catch (err) { + console.warn(`${LOG} Mayor SDK prewarm failed:`, err); + } finally { + const mayorPrewarmMs = Date.now() - mayorPrewarmStart; + log.info('bootHydration.mayor_prewarm_ms', { townId, durationMs: mayorPrewarmMs }); + postEventToWorker('bootHydration.mayor_prewarm_ms', { durationMs: mayorPrewarmMs }); } } - const mayorAlreadyResumed = (Array.isArray(registry) ? registry : []).some( - (e: unknown) => - typeof e === 'object' && - e !== null && - 'request' in e && - typeof (e as { request?: { role?: string } }).request?.role === 'string' && - (e as { request: { role: string } }).request.role === 'mayor' - ); - if (!mayorAlreadyResumed) { + if (nonMayorEntries.length > 0) { + setTimeout(() => { + void (async () => { + const nonMayorResumeStart = Date.now(); + await resumeRegistryEntries(LOG, nonMayorEntries, token); + const nonMayorResumeMs = Date.now() - nonMayorResumeStart; + log.info('bootHydration.non_mayor_resume_ms', { + townId, + durationMs: nonMayorResumeMs, + count: nonMayorEntries.length, + }); + postEventToWorker('bootHydration.non_mayor_resume_ms', { + durationMs: nonMayorResumeMs, + count: nonMayorEntries.length, + }); + })(); + }, 0); + } +} + +async function resumeRegistryEntries( + LOG: string, + entries: ContainerRegistryEntry[], + token: string +): Promise { + for (const entry of entries) { + // Registry entries were written with the token snapshot at dispatch + // time. If we just refreshed, overlay the fresh value so the hydrated + // kilo serve child inherits the current token. + const hydratedEnv = { ...entry.env, GASTOWN_CONTAINER_TOKEN: token }; + + console.log(`${LOG} Resuming agent ${entry.agentId} in ${entry.workdir}`); try { - await prewarmMayorSDK(townId, apiUrl, token); + await startAgent(entry.request, entry.workdir, hydratedEnv); + console.log(`${LOG} Agent ${entry.agentId} resumed`); } catch (err) { - console.warn(`${LOG} Mayor SDK prewarm failed:`, err); + const msg = err instanceof Error ? err.message : String(err); + console.error(`${LOG} Failed to resume agent ${entry.agentId}:`, msg); + } + } +} + +function parseRegistryEntries(LOG: string, registry: unknown): ContainerRegistryEntry[] { + if (!Array.isArray(registry)) return []; + + const entries: ContainerRegistryEntry[] = []; + for (const entry of registry) { + const parsed = ContainerRegistryEntry.safeParse(entry); + if (!parsed.success) { + console.warn(`${LOG} Skipping malformed registry entry:`, parsed.error.issues); + continue; } + entries.push(parsed.data); } + return entries; } diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index d112dbeea8..6c67a2369e 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -167,7 +167,14 @@ export async function ensureContainerToken( townId: string, userId: string ): Promise { + const mintStart = Date.now(); const token = await mintContainerToken(env, { townId, userId }); + writeEvent(env, { + event: 'startAgentInContainer.token_mint_ms', + townId, + userId, + durationMs: Date.now() - mintStart, + }); if (!token) { return null; } @@ -176,6 +183,7 @@ export async function ensureContainerToken( // Push to running process so existing agents pick up the fresh token. // Throw on non-2xx so the alarm's throttle doesn't advance on failure. + const refreshStart = Date.now(); try { const resp = await container.fetch('http://container/refresh-token', { method: 'POST', @@ -186,12 +194,27 @@ export async function ensureContainerToken( if (!resp.ok) { throw new Error(`container returned ${resp.status}`); } + writeEvent(env, { + event: 'startAgentInContainer.refresh_token_ms', + townId, + userId, + durationMs: Date.now() - refreshStart, + statusCode: resp.status, + }); } catch (err) { // If the container isn't running yet, the next dispatch will include // the token in request env. But if it IS running and rejected the // refresh, propagate the error so the alarm retries on the next tick. const isContainerDown = err instanceof TypeError || (err instanceof Error && err.message.includes('fetch')); + writeEvent(env, { + event: 'startAgentInContainer.refresh_token_ms', + townId, + userId, + durationMs: Date.now() - refreshStart, + error: err instanceof Error ? err.message : String(err), + label: isContainerDown ? 'container_down' : 'failed', + }); if (!isContainerDown) throw err; } @@ -404,8 +427,24 @@ export async function startAgentInContainer( try { // Mint a container-scoped JWT (8h expiry, refreshed by TownDO alarm). // One token per container — shared by all agents in the town. - // Carries { townId, userId, scope: 'container' }. - const containerToken = await ensureContainerToken(env, params.townId, params.userId); + // Carries { townId, userId, scope: 'container' }. Fresh dispatches + // pass the token in /agents/start instead of first pushing + // /refresh-token, keeping cold starts off the extra live request path. + // Already-running agents receive token rotation from the alarm or + // explicit refresh paths rather than this user-visible startup path. + const tokenMintStart = Date.now(); + const containerToken = await mintContainerToken(env, { + townId: params.townId, + userId: params.userId, + }); + writeEvent(env, { + event: 'startAgentInContainer.token_mint_ms', + townId: params.townId, + userId: params.userId, + agentId: params.agentId, + role: params.role, + durationMs: Date.now() - tokenMintStart, + }); // Also mint a per-agent JWT as fallback during rollout. const agentToken = await mintAgentToken(env, { @@ -466,6 +505,9 @@ export async function startAgentInContainer( } // Container token is preferred (shared by all agents, refreshed by alarm). + // This freshly minted token is for the agent being started; it is not + // pushed to existing SDK children here so mayor cold starts avoid a + // pre-start /refresh-token request. // Legacy per-agent JWT kept as fallback during rollout. if (containerToken) envVars.GASTOWN_CONTAINER_TOKEN = containerToken; if (agentToken) envVars.GASTOWN_SESSION_TOKEN = agentToken; diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 3d0427762e..5ccd605417 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -16,6 +16,7 @@ import { getGastownOrgStub } from '../dos/GastownOrg.do'; import type { JwtOrgMembership } from '../middleware/auth.middleware'; import { generateKiloApiToken } from '../util/kilo-token.util'; import { resolveSecret } from '../util/secret.util'; +import { writeEvent } from '../util/analytics.util'; import { TownConfigSchema, TownConfigUpdateSchema, RigOverrideConfigSchema } from '../types'; import { resolveModel } from '../dos/town/config'; import type { UserRigRecord } from '../db/tables/user-rigs.table'; @@ -76,6 +77,46 @@ async function refreshGitCredentials( }); } +async function refreshFirstGithubRigCredentials(params: { + env: Env; + townId: string; + ownerStub: RigOwnerStub; + credentialUserId: string; + organizationId?: string; +}): Promise { + const start = Date.now(); + try { + const rigList = await params.ownerStub.listRigs(params.townId); + for (const rig of rigList) { + if (extractGithubRepo(rig.git_url)) { + await refreshGitCredentials( + params.env, + params.townId, + rig.git_url, + params.credentialUserId, + params.organizationId + ); + break; + } + } + writeEvent(params.env, { + event: 'ensureMayor.git_credential_refresh_ms', + townId: params.townId, + userId: params.credentialUserId, + durationMs: Date.now() - start, + }); + } catch (err) { + writeEvent(params.env, { + event: 'ensureMayor.git_credential_refresh_ms', + townId: params.townId, + userId: params.credentialUserId, + durationMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }); + console.warn('[gastown-trpc] ensureMayor: git credential refresh failed', err); + } +} + // ── Helpers ──────────────────────────────────────────────────────────── /** Extract user identity fields from the tRPC context. */ @@ -1004,23 +1045,15 @@ export const gastownRouter = router({ // Best-effort: refresh git credentials using the town owner's identity const townConfig = await getTownDOStub(ctx.env, input.townId).getTownConfig(); const credentialUserId = townConfig.owner_user_id ?? ctx.userId; - try { - const rigList = await ownerStub.listRigs(input.townId); - for (const rig of rigList) { - if (extractGithubRepo(rig.git_url)) { - await refreshGitCredentials( - ctx.env, - input.townId, - rig.git_url, - credentialUserId, - townConfig.organization_id - ); - break; - } - } - } catch (err) { - console.warn('[gastown-trpc] ensureMayor: git credential refresh failed', err); - } + ctx.executionCtx.waitUntil( + refreshFirstGithubRigCredentials({ + env: ctx.env, + townId: input.townId, + ownerStub, + credentialUserId, + organizationId: townConfig.organization_id, + }) + ); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.ensureMayor(); From e56ecce53cb203b1b796270bc81bcbc21fe2b22f Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 21 May 2026 10:29:19 -0500 Subject: [PATCH 06/15] chore(gastown): Bump resources --- services/gastown/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index 1aec708d98..e3a9dff1f5 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -38,7 +38,7 @@ "class_name": "TownContainerDO", "image": "./container/Dockerfile", "instance_type": "standard-4", - "max_instances": 500, + "max_instances": 700, }, ], From b7de6db4438fb698d438602e6e7042c004e2d0e3 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 22 May 2026 10:36:05 -0500 Subject: [PATCH 07/15] feat(cloud-agent): add Gastown platform filter to sessions UI (#3426) * feat(cloud-agent): add Gastown filter option to ChatSidebar and mobile modal * fix(cloud-agent-next): remove stale comment in default branch handling --------- Co-authored-by: John Fawcett --- .../agents/platform-filter-modal.tsx | 4 ++++ .../cloud-agent-next/ChatSidebar.tsx | 3 +++ .../cloud-agent-next/CloudSidebarLayout.tsx | 3 ++- .../container/src/process-manager.test.ts | 24 ++++++++----------- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index 5a3f4dea26..a12e03ab5e 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'; const PLATFORM_FILTERS = [ 'cloud-agent', 'extension', + 'gastown', 'cli', 'slack', 'github', @@ -80,6 +81,9 @@ function platformFilterLabel(p: string): string { case 'other': { return 'Other'; } + case 'gastown': { + return 'Gastown'; + } default: { return p; } diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index f91ffd6946..fa28a64b0d 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -291,6 +291,7 @@ function SessionRow({ const PLATFORM_FILTERS = [ 'cloud-agent', 'extension', + 'gastown', 'cli', 'slack', 'github', @@ -314,6 +315,8 @@ function platformFilterLabel(p: string): string { return 'Linear'; case 'other': return 'Other'; + case 'gastown': + return 'Gastown'; default: return p; } diff --git a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 18b2c0c0d4..6026a322e5 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -67,13 +67,14 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay if (platformFilter.length === 0) return undefined; return platformFilter.flatMap(p => { switch (p) { - // 'cloud-agent-web' is a variant of the cloud agent + case 'cloud-agent': return ['cloud-agent', 'cloud-agent-web']; // Extension sessions are created from VS Code or agent-manager case 'extension': return ['vscode', 'agent-manager']; default: + // Handles platforms like 'gastown' by passing through unchanged return [p]; } }); diff --git a/services/gastown/container/src/process-manager.test.ts b/services/gastown/container/src/process-manager.test.ts index 1e06e7c193..bd65ed23a7 100644 --- a/services/gastown/container/src/process-manager.test.ts +++ b/services/gastown/container/src/process-manager.test.ts @@ -14,18 +14,16 @@ vi.mock('./agent-runner', () => ({ (kilocodeToken: string, model: string, smallModel: string, organizationId?: string) => JSON.stringify({ kilocodeToken, model, smallModel, organizationId }) ), - buildKiloAuthEnv: vi.fn( - (kilocodeToken?: string, organizationId?: string | null) => { - const authEnv: Record = { KILO_PLATFORM: 'gastown' }; - if (kilocodeToken) { - authEnv.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: 'api', key: kilocodeToken } }); - } - if (organizationId) { - authEnv.KILO_ORG_ID = organizationId; - } - return authEnv; + buildKiloAuthEnv: vi.fn((kilocodeToken?: string, organizationId?: string | null) => { + const authEnv: Record = { KILO_PLATFORM: 'gastown' }; + if (kilocodeToken) { + authEnv.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: 'api', key: kilocodeToken } }); } - ), + if (organizationId) { + authEnv.KILO_ORG_ID = organizationId; + } + return authEnv; + }), resolveGitCredentials: vi.fn(), writeMayorSystemPromptToAgentsMd: vi.fn(), ensureMayorWorkspaceForTown: vi.fn(async (_townId: string) => TEST_WORKSPACE), @@ -288,9 +286,7 @@ describe('awaitHydration', () => { }); expect(env?.KILO_CONFIG_CONTENT).toBeTruthy(); expect(env?.KILO_PLATFORM).toBe('gastown'); - expect(env?.KILO_AUTH_CONTENT).toBe( - JSON.stringify({ kilo: { type: 'api', key: 'kc-tok' } }) - ); + expect(env?.KILO_AUTH_CONTENT).toBe(JSON.stringify({ kilo: { type: 'api', key: 'kc-tok' } })); expect(env?.KILO_ORG_ID).toBeUndefined(); } finally { globalThis.fetch = originalFetch; From 7e325c36ac0b7aa0f9a0d354ea88a7aec4338ee2 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 22 May 2026 11:04:58 -0500 Subject: [PATCH 08/15] fix(gastown): reuse existing worktree branches Make worktree creation tolerate an existing local branch with a missing worktree directory, and allow internal @kilocode package updates to bypass release-age delays. --- .../cloud-agent-next/CloudSidebarLayout.tsx | 1 - pnpm-workspace.yaml | 1 + services/gastown/container/src/git-manager.ts | 50 +++++++++++-------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 6026a322e5..8434c0952a 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -67,7 +67,6 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay if (platformFilter.length === 0) return undefined; return platformFilter.flatMap(p => { switch (p) { - case 'cloud-agent': return ['cloud-agent', 'cloud-agent-web']; // Extension sessions are created from VS Code or agent-manager diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96c20040fe..1a75b937a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,6 +42,7 @@ catalog: minimumReleaseAge: 6842 minimumReleaseAgeExclude: + - '@kilocode/*' - tsx - expo-dev-client - expo-dev-launcher diff --git a/services/gastown/container/src/git-manager.ts b/services/gastown/container/src/git-manager.ts index db1e730ace..166cda5f50 100644 --- a/services/gastown/container/src/git-manager.ts +++ b/services/gastown/container/src/git-manager.ts @@ -451,6 +451,12 @@ async function pathExists(p: string): Promise { } } +async function localBranchExists(repo: string, branch: string): Promise { + return exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], repo) + .then(() => true) + .catch(() => false); +} + async function repoDir(rigId: string): Promise { validatePathSegment(rigId, 'rigId'); const dir = resolve(WORKSPACE_ROOT, rigId, 'repo'); @@ -582,6 +588,7 @@ export function createWorktree(options: WorktreeOptions): Promise { } async function createWorktreeInner(options: WorktreeOptions): Promise { + validateBranchName(options.branch, 'branch'); const repo = await repoDir(options.rigId); const dir = await worktreeDir(options.rigId, options.branch); @@ -611,29 +618,32 @@ async function createWorktreeInner(options: WorktreeOptions): Promise { ); } - // When a startPoint is provided (e.g. a convoy feature branch), create - // the new branch from that ref so the agent begins with the latest - // merged work from upstream. Without a startPoint, try to track the - // remote branch or fall back to the repo's current HEAD. - const startPoint = options.startPoint; - try { - if (startPoint) { - await exec('git', ['branch', options.branch, startPoint], repo); - } else { - await exec('git', ['branch', '--track', options.branch, `origin/${options.branch}`], repo); - } - } catch { - // Fall back to origin/ so we always branch from the - // latest remote tip rather than the repo's local HEAD (which may be - // stale in a --no-checkout bare clone). - const fallback = options.defaultBranch ? `origin/${options.defaultBranch}` : undefined; - if (fallback) { - await exec('git', ['branch', options.branch, fallback], repo); - } else { - await exec('git', ['branch', options.branch], repo); + if (!(await localBranchExists(repo, options.branch))) { + // When a startPoint is provided (e.g. a convoy feature branch), create + // the new branch from that ref so the agent begins with the latest + // merged work from upstream. Without a startPoint, try to track the + // remote branch or fall back to the repo's current HEAD. + const startPoint = options.startPoint; + try { + if (startPoint) { + await exec('git', ['branch', options.branch, startPoint], repo); + } else { + await exec('git', ['branch', '--track', options.branch, `origin/${options.branch}`], repo); + } + } catch { + // Fall back to origin/ so we always branch from the + // latest remote tip rather than the repo's local HEAD (which may be + // stale in a --no-checkout bare clone). + const fallback = options.defaultBranch ? `origin/${options.defaultBranch}` : undefined; + if (fallback) { + await exec('git', ['branch', options.branch, fallback], repo); + } else { + await exec('git', ['branch', options.branch], repo); + } } } + await exec('git', ['worktree', 'prune'], repo).catch(() => {}); await exec('git', ['worktree', 'add', dir, options.branch], repo); console.log(`Created worktree for branch ${options.branch} at ${dir}`); return dir; From 8876540d5deb019c1393789fd5d60013d54f2deb Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 28 May 2026 11:33:48 -0500 Subject: [PATCH 09/15] fix(gastown): guard eviction resets with heartbeat (#3566) --- services/gastown/src/dos/town/reconciler.ts | 12 ++---- services/gastown/src/dos/town/review-queue.ts | 42 +++++++++++-------- .../gastown/src/dos/town/staleness.test.ts | 30 +++++++++++++ services/gastown/src/dos/town/staleness.ts | 10 +++++ .../test/integration/reconciler.test.ts | 32 ++++++++++++++ 5 files changed, 100 insertions(+), 26 deletions(-) create mode 100644 services/gastown/src/dos/town/staleness.test.ts create mode 100644 services/gastown/src/dos/town/staleness.ts diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 17f72ac47c..8382683d48 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -45,6 +45,7 @@ import { isAlreadyReported, type ReporterBead, } from './wasteland-reporter'; +import { HEARTBEAT_STALE_MS, staleMs } from './staleness'; const LOG = '[reconciler]'; @@ -131,13 +132,6 @@ const STALE_IN_PROGRESS_TIMEOUT_MS = 5 * 60_000; // 5 min * cleanup closes the loop. */ const STALLED_AUTO_IDLE_MS = 2.5 * 60 * 60_000; // 2h 30min -// ── Helper: staleness check ───────────────────────────────────────── - -function staleMs(timestamp: string | null, thresholdMs: number): boolean { - if (!timestamp) return true; - return Date.now() - new Date(timestamp).getTime() > thresholdMs; -} - /** * Compute the dispatch cooldown for a bead based on its attempt count. * Implements exponential backoff: @@ -728,7 +722,7 @@ export function reconcileAgents(sql: SqlStorage, opts?: { draining?: boolean }): to: 'idle', reason: 'no heartbeat received since dispatch', }); - } else if (staleMs(agent.last_activity_at, 90_000)) { + } else if (staleMs(agent.last_activity_at, HEARTBEAT_STALE_MS)) { actions.push({ type: 'transition_agent', agent_id: agent.bead_id, @@ -907,7 +901,7 @@ export function reconcileAgents(sql: SqlStorage, opts?: { draining?: boolean }): // alive and leave the hook in place. The 90s window matches // reconcileAgents' stale-heartbeat threshold, so a truly dead // agent still gets reaped by the heartbeat path on a later tick. - if (!staleMs(agent.last_activity_at, 90_000)) continue; + if (!staleMs(agent.last_activity_at, HEARTBEAT_STALE_MS)) continue; actions.push({ type: 'unhook_agent', diff --git a/services/gastown/src/dos/town/review-queue.ts b/services/gastown/src/dos/town/review-queue.ts index 3246db07da..db6ed8b2b4 100644 --- a/services/gastown/src/dos/town/review-queue.ts +++ b/services/gastown/src/dos/town/review-queue.ts @@ -28,6 +28,7 @@ import { import { getAgent, unhookBead, updateAgentStatus } from './agents'; import { getRig } from './rigs'; import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; +import { heartbeatStale } from './staleness'; function generateId(): string { return crypto.randomUUID(); @@ -735,23 +736,30 @@ export function agentCompleted( }); } else if (hookedBead && hookedBead.status === 'in_progress') { if (input.reason === 'container eviction') { - // Container eviction: WIP was force-pushed and eviction context - // was written on the bead body. Reset to open and clear the - // stale assignee so the reconciler can re-dispatch immediately. - console.log( - `[review-queue] agentCompleted: polecat ${agentId} evicted — ` + - `resetting bead ${agent.current_hook_bead_id} to open` - ); - updateBeadStatus(sql, agent.current_hook_bead_id, 'open', agentId); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.assignee_agent_bead_id} = NULL - WHERE ${beads.bead_id} = ? - `, - [agent.current_hook_bead_id] - ); + if (!heartbeatStale(agent.last_activity_at)) { + console.log( + `[review-queue] agentCompleted: polecat ${agentId} eviction event ignored — ` + + `heartbeat fresh; bead ${agent.current_hook_bead_id} stays in_progress` + ); + } else { + // Container eviction with a stale heartbeat: WIP was force-pushed and eviction + // context was written on the bead body. Reset to open and clear the stale + // assignee so the reconciler can re-dispatch immediately. + console.log( + `[review-queue] agentCompleted: polecat ${agentId} evicted (heartbeat stale) — ` + + `resetting bead ${agent.current_hook_bead_id} to open` + ); + updateBeadStatus(sql, agent.current_hook_bead_id, 'open', agentId); + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.assignee_agent_bead_id} = NULL + WHERE ${beads.bead_id} = ? + `, + [agent.current_hook_bead_id] + ); + } } else { // Agent exited 'completed' but bead is still in_progress — gt_done was never called. // Don't close the bead. Rule 3 will handle rework. diff --git a/services/gastown/src/dos/town/staleness.test.ts b/services/gastown/src/dos/town/staleness.test.ts new file mode 100644 index 0000000000..451b9f220c --- /dev/null +++ b/services/gastown/src/dos/town/staleness.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HEARTBEAT_STALE_MS, heartbeatStale } from './staleness'; + +describe('heartbeatStale', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('treats a fresh heartbeat as live', () => { + vi.useFakeTimers(); + const now = new Date('2026-05-28T12:00:00.000Z'); + vi.setSystemTime(now); + + expect(heartbeatStale(new Date(now.getTime() - 1_000).toISOString())).toBe(false); + }); + + it('treats a heartbeat past the threshold as stale', () => { + vi.useFakeTimers(); + const now = new Date('2026-05-28T12:00:00.000Z'); + vi.setSystemTime(now); + + expect(heartbeatStale(new Date(now.getTime() - HEARTBEAT_STALE_MS - 1).toISOString())).toBe( + true + ); + }); + + it('treats a missing heartbeat as stale', () => { + expect(heartbeatStale(null)).toBe(true); + }); +}); diff --git a/services/gastown/src/dos/town/staleness.ts b/services/gastown/src/dos/town/staleness.ts new file mode 100644 index 0000000000..6fb5fb1a1c --- /dev/null +++ b/services/gastown/src/dos/town/staleness.ts @@ -0,0 +1,10 @@ +export const HEARTBEAT_STALE_MS = 90_000; + +export function staleMs(timestamp: string | null, thresholdMs: number): boolean { + if (!timestamp) return true; + return Date.now() - new Date(timestamp).getTime() > thresholdMs; +} + +export function heartbeatStale(timestamp: string | null): boolean { + return staleMs(timestamp, HEARTBEAT_STALE_MS); +} diff --git a/services/gastown/test/integration/reconciler.test.ts b/services/gastown/test/integration/reconciler.test.ts index e5e2869245..e7947f3a5a 100644 --- a/services/gastown/test/integration/reconciler.test.ts +++ b/services/gastown/test/integration/reconciler.test.ts @@ -237,6 +237,38 @@ describe('Reconciler', () => { }); }); + describe('event-driven agentCompleted container eviction', () => { + it('keeps an in-progress bead assigned when the agent heartbeat is fresh', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P1', + identity: `eviction-fresh-${townName}`, + rig_id: 'rig-1', + }); + const bead = await town.createBead({ + type: 'issue', + title: 'Fresh eviction bead', + rig_id: 'rig-1', + }); + + await town.hookBead(agent.id, bead.bead_id); + await town.updateBeadStatus(bead.bead_id, 'in_progress', agent.id); + await town.updateAgentStatus(agent.id, 'working'); + await town.touchAgentHeartbeat(agent.id); + + await town.agentCompleted(agent.id, { status: 'completed', reason: 'container eviction' }); + await runDurableObjectAlarm(town); + + const beadAfter = await town.getBeadAsync(bead.bead_id); + expect(beadAfter?.status).toBe('in_progress'); + expect(beadAfter?.assignee_agent_bead_id).toBe(agent.id); + + const agentAfter = await town.getAgentAsync(agent.id); + expect(agentAfter?.status).toBe('idle'); + expect(agentAfter?.current_hook_bead_id).toBeNull(); + }); + }); + // ── reconcileReviewQueue Rule 5: Refinery dispatch ────────────────── describe('reconcileReviewQueue Rule 5: refinery dispatch', () => { From 59b5d6930f4d9de8df3447f48af98e85821e5f33 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 28 May 2026 15:09:33 -0500 Subject: [PATCH 10/15] fix(gastown): treat agent events as liveness --- .../gastown/container/src/control-server.ts | 10 ++++ services/gastown/container/src/heartbeat.ts | 4 ++ .../gastown/docs/post-deploy-monitoring.md | 27 +++++++---- services/gastown/scripts/monitor-town.sh | 24 ++++++---- services/gastown/src/dos/Town.do.ts | 8 ++++ services/gastown/src/dos/town/agents.ts | 4 +- .../test/integration/reconciler.test.ts | 48 ++++++++++++++++++- 7 files changed, 105 insertions(+), 20 deletions(-) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 8eb7b559ea..f5d116017b 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -169,6 +169,12 @@ function syncTownConfigToProcessEnv(): void { process.env[key] = String(value); } lastAppliedEnvVarKeys = newCustomKeys; + + const apiUrl = process.env.GASTOWN_API_URL; + const authToken = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN; + if (apiUrl && authToken) { + startHeartbeat(apiUrl, authToken); + } } export const app = new Hono(); @@ -354,6 +360,10 @@ app.post('/agents/start', async c => { if (parsed.data.envVars?.GASTOWN_CONTAINER_TOKEN) { process.env.GASTOWN_CONTAINER_TOKEN = parsed.data.envVars.GASTOWN_CONTAINER_TOKEN; } + const heartbeatToken = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN; + if (process.env.GASTOWN_API_URL && heartbeatToken) { + startHeartbeat(process.env.GASTOWN_API_URL, heartbeatToken); + } console.log( `[control-server] /agents/start: role=${parsed.data.role} name=${parsed.data.name} rigId=${parsed.data.rigId} agentId=${parsed.data.agentId}` diff --git a/services/gastown/container/src/heartbeat.ts b/services/gastown/container/src/heartbeat.ts index 2d6e328599..fa9ff4c330 100644 --- a/services/gastown/container/src/heartbeat.ts +++ b/services/gastown/container/src/heartbeat.ts @@ -23,6 +23,10 @@ const CONTAINER_INSTANCE_ID = crypto.randomUUID(); * which forwards them to the Rig DO to update `last_activity_at`. */ export function startHeartbeat(apiUrl: string, token: string): void { + if (heartbeatTimer && gastownApiUrl === apiUrl && sessionToken === token) { + return; + } + gastownApiUrl = apiUrl; sessionToken = token; diff --git a/services/gastown/docs/post-deploy-monitoring.md b/services/gastown/docs/post-deploy-monitoring.md index 94e4122380..91c2144f6f 100644 --- a/services/gastown/docs/post-deploy-monitoring.md +++ b/services/gastown/docs/post-deploy-monitoring.md @@ -5,13 +5,19 @@ Guide for an AI agent to verify town health after a production deploy. ## Prerequisites - The debug endpoint is deployed: `GET /debug/towns/:townId/status` -- The debug endpoint is protected by Cloudflare Access. Requests must include service token headers. +- The debug endpoint is protected by Cloudflare Access. Requests must include a Cloudflare Access JWT. - Base URL: `https://gastown.kiloapps.io` - Town ID: obtain from `GET /trpc/gastown.listOrgTowns` (requires auth) or from the user ### Authentication -The debug endpoint requires Cloudflare Access service token headers. These are the same credentials the Next.js app uses to communicate with gastown: +The debug endpoint requires a Cloudflare Access token. For interactive debugging, obtain one with `cloudflared`: + +```bash +export CF_ACCESS_TOKEN="$(cloudflared access login --no-verbose https://gastown.kiloapps.io/debug/login)" +``` + +Service token headers also work when using the same credentials the Next.js app uses to communicate with gastown: ```bash # Set these from your Cloudflare Access service token @@ -28,14 +34,18 @@ export GASTOWN_CF_ANALYTICS_API_KEY="" export CF_ACCOUNT_ID="" ``` -All `curl` commands in this document use a helper function that includes these headers: +All `curl` commands in this document use a helper function. Prefer the interactive token when available; fall back to service token headers if set: ```bash debug_curl() { - curl -s \ - -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ - -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ - "$@" + if [ -n "${CF_ACCESS_TOKEN:-}" ]; then + curl -s -H "cf-access-token: $CF_ACCESS_TOKEN" "$@" + else + curl -s \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "$@" + fi } ``` @@ -44,8 +54,7 @@ debug_curl() { The monitoring script at `scripts/monitor-town.sh` polls the debug endpoint: ```bash -export CF_ACCESS_CLIENT_ID="" -export CF_ACCESS_CLIENT_SECRET="" +export CF_ACCESS_TOKEN="$(cloudflared access login --no-verbose https://gastown.kiloapps.io/debug/login)" ./scripts/monitor-town.sh [interval_seconds] ``` diff --git a/services/gastown/scripts/monitor-town.sh b/services/gastown/scripts/monitor-town.sh index ef3075e56c..6d3f9d9a68 100755 --- a/services/gastown/scripts/monitor-town.sh +++ b/services/gastown/scripts/monitor-town.sh @@ -2,7 +2,9 @@ # Continuously monitor a town's state via the debug endpoint. # Usage: ./scripts/monitor-town.sh [townId] [interval_seconds] # -# Requires Cloudflare Access service token credentials: +# Requires Cloudflare Access credentials. Prefer an interactive JWT: +# export CF_ACCESS_TOKEN="$(cloudflared access login --no-verbose https://gastown.kiloapps.io/debug/login)" +# Or use service token credentials: # export CF_ACCESS_CLIENT_ID="" # export CF_ACCESS_CLIENT_SECRET="" @@ -11,9 +13,9 @@ INTERVAL="${2:-15}" BASE_URL="${GASTOWN_URL:-https://gastown.kiloapps.io}" URL="${BASE_URL}/debug/towns/${TOWN_ID}/status" -if [ -z "$CF_ACCESS_CLIENT_ID" ] || [ -z "$CF_ACCESS_CLIENT_SECRET" ]; then - echo "Error: CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET must be set" - echo "These are the Cloudflare Access service token credentials." +if [ -z "${CF_ACCESS_TOKEN:-}" ] && { [ -z "${CF_ACCESS_CLIENT_ID:-}" ] || [ -z "${CF_ACCESS_CLIENT_SECRET:-}" ]; }; then + echo "Error: CF_ACCESS_TOKEN or CF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET must be set" + echo "Run: export CF_ACCESS_TOKEN=\"\$(cloudflared access login --no-verbose https://gastown.kiloapps.io/debug/login)\"" exit 1 fi @@ -23,10 +25,16 @@ echo "Press Ctrl+C to stop" echo "==========================================" while true; do - RESP=$(curl -s --max-time 10 \ - -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ - -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ - "${URL}" 2>/dev/null) + if [ -n "${CF_ACCESS_TOKEN:-}" ]; then + RESP=$(curl -s --max-time 10 \ + -H "cf-access-token: $CF_ACCESS_TOKEN" \ + "${URL}" 2>/dev/null) + else + RESP=$(curl -s --max-time 10 \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "${URL}" 2>/dev/null) + fi if [ -z "$RESP" ]; then echo "$(date -u +%H:%M:%S) [ERROR] No response from ${URL}" sleep "$INTERVAL" diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index cbb727e00a..cede526240 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -1619,6 +1619,10 @@ export class TownDO extends DurableObject { // ── Agent Events (delegated to AgentDO) ─────────────────────────── async appendAgentEvent(agentId: string, eventType: string, data: unknown): Promise { + agents.touchAgent(this.sql, agentId, { + lastEventType: eventType, + lastEventAt: new Date().toISOString(), + }); const agentDO = getAgentDOStub(this.env, agentId); return agentDO.appendEvent(eventType, data); } @@ -1741,6 +1745,10 @@ export class TownDO extends DurableObject { } async updateAgentStatusMessage(agentId: string, message: string): Promise { + agents.touchAgent(this.sql, agentId, { + lastEventType: 'agent_status', + lastEventAt: new Date().toISOString(), + }); agents.updateAgentStatusMessage(this.sql, agentId, message); const agent = agents.getAgent(this.sql, agentId); if (agent?.current_hook_bead_id) { diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index c2b920c13e..6b54a08413 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -601,7 +601,7 @@ export function updateAgentStatusMessage(sql: SqlStorage, agentId: string, messa ); } -// ── Touch (heartbeat helper) ──────────────────────────────────────── +// ── Touch (container activity helper) ──────────────────────────────── export function touchAgent( sql: SqlStorage, @@ -612,7 +612,7 @@ export function touchAgent( activeTools?: string[]; } ): void { - // A heartbeat is proof the agent is alive in the container. + // A heartbeat, agent event, or status update 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 diff --git a/services/gastown/test/integration/reconciler.test.ts b/services/gastown/test/integration/reconciler.test.ts index e7947f3a5a..72c1633f6b 100644 --- a/services/gastown/test/integration/reconciler.test.ts +++ b/services/gastown/test/integration/reconciler.test.ts @@ -126,7 +126,7 @@ describe('Reconciler', () => { }); }); - // ── #1358: Heartbeat restores working status ──────────────────────── + // ── #1358: Container activity restores working status ─────────────── describe('#1358: dispatch timeout race recovery', () => { it('should restore idle agent to working on heartbeat', async () => { @@ -195,6 +195,52 @@ describe('Reconciler', () => { const after = await town.getAgentAsync(agent.id); expect(after?.status).toBe('exited'); }); + + it('should restore idle agent to working on agent event activity', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P4', + identity: `agent-event-restore-${townName}`, + rig_id: 'rig-1', + }); + const bead = await town.createBead({ + type: 'issue', + title: 'Agent event activity test', + rig_id: 'rig-1', + }); + + await town.hookBead(agent.id, bead.bead_id); + await town.updateAgentStatus(agent.id, 'idle'); + + await town.appendAgentEvent(agent.id, 'message', { text: 'working' }); + + const after = await town.getAgentAsync(agent.id); + expect(after?.status).toBe('working'); + expect(after?.last_activity_at).toBeTruthy(); + }); + + it('should restore idle agent to working on status message activity', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'P5', + identity: `status-message-restore-${townName}`, + rig_id: 'rig-1', + }); + const bead = await town.createBead({ + type: 'issue', + title: 'Status message activity test', + rig_id: 'rig-1', + }); + + await town.hookBead(agent.id, bead.bead_id); + await town.updateAgentStatus(agent.id, 'idle'); + + await town.updateAgentStatusMessage(agent.id, 'Still working'); + + const after = await town.getAgentAsync(agent.id); + expect(after?.status).toBe('working'); + expect(after?.last_activity_at).toBeTruthy(); + }); }); // ── Event-driven agentDone ────────────────────────────────────────── From ed9a59886e16d04ad04ffd805e82e4c8233bdb79 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 28 May 2026 15:37:35 -0500 Subject: [PATCH 11/15] feat(gastown): tRPC + HTTP for babysit PR (chunk 1) (#3580) * fix(gastown): bump @kilocode/cli to 7.3.1 + plumb auth env into prewarm (#3372) Bug 1: @kilocode/cli@7.2.14 doesn't read KILO_AUTH_CONTENT, causing all kilo serve session-ingest to silently no-op. Bumped to 7.3.1 which has the feature. Verified KILO_AUTH_CONTENT present in binary strings. Bug 2: buildPrewarmEnv didn't set KILO_AUTH_CONTENT, KILO_PLATFORM, or KILO_ORG_ID, so mayor sessions (which go through prewarm) were invisible. Extracted buildKiloAuthEnv helper from buildAgentEnv and used it in both buildAgentEnv and buildPrewarmEnv. Refs #3307 Co-authored-by: John Fawcett * feat(gastown): Town DO slingExistingPr method (babysit PR feature, chunk 0) (#3576) * feat(gastown): Town DO slingExistingPr method (babysit PR feature, chunk 0) * fix(gastown): address PR review feedback on slingExistingPr - Remove async from submitExternalPrToReviewQueue (no awaits inside) - Add GitLab host validation in slingExistingPr URL parsing to prevent non-GitLab URLs (e.g. Bitbucket) from matching the GitLab regex - Replace vacuous state validation tests with meaningful checkPRStatus integration tests that exercise the actual status resolution path --------- Co-authored-by: John Fawcett * feat(gastown): tRPC + HTTP for babysit PR (chunk 1) Add gastown.babysitPr tRPC mutation, gastown.previewPr tRPC query, mayor-tools babysit-pr HTTP route, refinery bypass for babysat beads, and reconciler fast-track extension for babysat MR beads. * chore: remove duplicate sling-existing-pr.test.ts The file was a verbatim copy of the first 442 lines of babysit-pr.test.ts, causing 24 tests to run twice with zero added coverage. * fix(gastown): resolve merge conflict markers in process-manager.test.ts --------- Co-authored-by: John Fawcett --- .../src/db/tables/bead-events.table.ts | 1 + services/gastown/src/dos/Town.do.ts | 212 +++++++ services/gastown/src/dos/town/actions.ts | 11 +- services/gastown/src/dos/town/reconciler.ts | 35 ++ services/gastown/src/dos/town/review-queue.ts | 177 ++++-- services/gastown/src/dos/town/town-scm.ts | 75 ++- services/gastown/src/gastown.worker.ts | 6 + .../src/handlers/mayor-tools.handler.ts | 47 ++ services/gastown/src/trpc/router.ts | 43 ++ services/gastown/src/trpc/schemas.ts | 18 + services/gastown/src/util/platform-pr.util.ts | 18 +- services/gastown/test/unit/babysit-pr.test.ts | 571 ++++++++++++++++++ 12 files changed, 1152 insertions(+), 62 deletions(-) create mode 100644 services/gastown/test/unit/babysit-pr.test.ts diff --git a/services/gastown/src/db/tables/bead-events.table.ts b/services/gastown/src/db/tables/bead-events.table.ts index 1a9ac8219b..3f2937e748 100644 --- a/services/gastown/src/db/tables/bead-events.table.ts +++ b/services/gastown/src/db/tables/bead-events.table.ts @@ -24,6 +24,7 @@ export const BeadEventType = z.enum([ 'escalation_rate_spike', 'agent_restart_loop', 'rework_requested', + 'babysit_started', ]); export type BeadEventType = z.infer; diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index cede526240..336c7ab210 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -71,6 +71,7 @@ import { kiloTokenPayload } from '@kilocode/worker-utils'; import { jwtVerify } from 'jose'; import { generateKiloApiToken } from '../util/kilo-token.util'; import { resolveSecret } from '../util/secret.util'; +import { parseGitUrl } from '../util/platform-pr.util'; import { writeEvent, type GastownEventData } from '../util/analytics.util'; import { logger, withLogTags } from '../util/log.util'; import { BeadPriority } from '../types'; @@ -2539,6 +2540,217 @@ export class TownDO extends DurableObject { return { bead, agent: hookedAgent }; } + async slingExistingPr(input: { + rigId: string; + prUrl: string; + title?: string; + body?: string; + forcePushAllowed?: boolean; + sourceAgentId?: string; + }): Promise<{ beadId: string; warning?: string }> { + const rig = rigs.getRig(this.sql, input.rigId); + if (!rig) { + throw new Error(`Rig not found: ${input.rigId}`); + } + + const townConfig = await this.getTownConfig(); + const rigCoords = parseGitUrl(rig.git_url, townConfig.git_auth?.gitlab_instance_url); + if (!rigCoords) { + throw new Error(`Cannot parse rig git URL: ${rig.git_url}`); + } + + const prUrlMatch = input.prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + const glUrlMatch = input.prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); + + let prOwner: string | undefined; + let prRepo: string | undefined; + let prPlatform: 'github' | 'gitlab' | undefined; + + if (prUrlMatch) { + prPlatform = 'github'; + prOwner = prUrlMatch[1]; + prRepo = prUrlMatch[2]; + } else if (glUrlMatch) { + const glHost = new URL(glUrlMatch[1]).hostname; + const configuredGlHost = townConfig.git_auth?.gitlab_instance_url + ? new URL(townConfig.git_auth.gitlab_instance_url).hostname + : null; + if (glHost === 'gitlab.com' || glHost === configuredGlHost) { + prPlatform = 'gitlab'; + const fullPath = glUrlMatch[2]; + const lastSlash = fullPath.lastIndexOf('/'); + prOwner = lastSlash > 0 ? fullPath.slice(0, lastSlash) : fullPath; + prRepo = lastSlash > 0 ? fullPath.slice(lastSlash + 1) : ''; + } + } + + if (!prPlatform || !prOwner || !prRepo) { + throw new Error(`Cannot parse PR URL: ${input.prUrl}`); + } + + if ( + prPlatform !== rigCoords.platform || + prOwner !== rigCoords.owner || + prRepo !== rigCoords.repo + ) { + throw new Error( + 'PR URL repo does not match rig repo. Cannot adopt PRs from other repositories.' + ); + } + + const rigConfig = await this.getRigConfig(input.rigId); + const scmCtx: scm.SCMContext = { + env: this.env, + townId: this.townId, + getTownConfig: () => this.getTownConfig(), + platformIntegrationId: rigConfig?.platformIntegrationId, + }; + + const outcome = await scm.checkPRStatus(scmCtx, input.prUrl); + if (!outcome.ok) { + const err = outcome.error; + throw new Error( + `Failed to fetch PR status: ${err.kind}` + + (err.kind === 'http_error' ? ` (${err.status} ${err.statusText})` : '') + + (err.kind === 'no_token' ? ` — tried: ${err.resolutionChain.join(', ')}` : '') + ); + } + + const prResult = outcome.result; + if (prResult.status === 'merged' || prResult.status === 'closed') { + throw new Error(`Cannot babysit a PR that is already ${prResult.status}.`); + } + + const headBranch = prResult.head_branch; + const baseBranch = prResult.base_branch; + const headSha = prResult.head_sha; + const prTitle = prResult.title; + + if (!headBranch || !baseBranch || !headSha) { + throw new Error( + `PR metadata incomplete — missing head_branch, base_branch, or head_sha. ` + + `head_branch=${headBranch ?? 'missing'}, base_branch=${baseBranch ?? 'missing'}, head_sha=${headSha ?? 'missing'}` + ); + } + + const title = input.title ?? `Babysit: ${prTitle ?? input.prUrl}`; + const body = + input.body ?? + `Adopted external PR ${input.prUrl} at SHA ${headSha}. Town will poll and address review feedback, conflicts, and auto-merge.`; + + const { beadId } = reviewQueue.submitExternalPrToReviewQueue(this.sql, { + rigId: input.rigId, + prUrl: input.prUrl, + branch: headBranch, + targetBranch: baseBranch, + headSha, + title, + body, + forcePushAllowed: input.forcePushAllowed ?? false, + sourceAgentId: input.sourceAgentId ?? 'mayor', + }); + + events.insertEvent(this.sql, 'bead_created', { + bead_id: beadId, + payload: { bead_type: 'merge_request', rig_id: input.rigId, babysit: true }, + }); + + await this.escalateToActiveCadence(); + + let warning: string | undefined; + const effectiveConfig = config.resolveRigConfig(townConfig, rig.config ?? null); + if (effectiveConfig.code_review) { + warning = + 'This rig has code review enabled. Babysat PRs bypass refinery code review — the town will poll and auto-merge instead of dispatching a refinery.'; + } + + return { beadId, warning }; + } + + async previewPr(input: { rigId: string; prUrl: string }): Promise<{ + state: string; + head_branch?: string; + base_branch?: string; + head_sha?: string; + title?: string; + repo_matches: boolean; + }> { + const rig = rigs.getRig(this.sql, input.rigId); + if (!rig) { + throw new Error(`Rig not found: ${input.rigId}`); + } + + const townConfig = await this.getTownConfig(); + const rigCoords = parseGitUrl(rig.git_url, townConfig.git_auth?.gitlab_instance_url); + if (!rigCoords) { + throw new Error(`Cannot parse rig git URL: ${rig.git_url}`); + } + + const prUrlMatch = input.prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + const glUrlMatch = input.prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); + + let prOwner: string | undefined; + let prRepo: string | undefined; + let prPlatform: 'github' | 'gitlab' | undefined; + + if (prUrlMatch) { + prPlatform = 'github'; + prOwner = prUrlMatch[1]; + prRepo = prUrlMatch[2]; + } else if (glUrlMatch) { + const glHost = new URL(glUrlMatch[1]).hostname; + const configuredGlHost = townConfig.git_auth?.gitlab_instance_url + ? new URL(townConfig.git_auth.gitlab_instance_url).hostname + : null; + if (glHost === 'gitlab.com' || glHost === configuredGlHost) { + prPlatform = 'gitlab'; + const fullPath = glUrlMatch[2]; + const lastSlash = fullPath.lastIndexOf('/'); + prOwner = lastSlash > 0 ? fullPath.slice(0, lastSlash) : fullPath; + prRepo = lastSlash > 0 ? fullPath.slice(lastSlash + 1) : ''; + } + } + + if (!prPlatform || !prOwner || !prRepo) { + throw new Error(`Cannot parse PR URL: ${input.prUrl}`); + } + + const repoMatches = + prPlatform === rigCoords.platform && prOwner === rigCoords.owner && prRepo === rigCoords.repo; + + if (!repoMatches) { + return { state: 'unknown', repo_matches: false }; + } + + const rigConfig = await this.getRigConfig(input.rigId); + const scmCtx: scm.SCMContext = { + env: this.env, + townId: this.townId, + getTownConfig: () => this.getTownConfig(), + platformIntegrationId: rigConfig?.platformIntegrationId, + }; + + const outcome = await scm.checkPRStatus(scmCtx, input.prUrl); + if (!outcome.ok) { + const err = outcome.error; + throw new Error( + `Failed to fetch PR status: ${err.kind}` + + (err.kind === 'http_error' ? ` (${err.status} ${err.statusText})` : '') + + (err.kind === 'no_token' ? ` — tried: ${err.resolutionChain.join(', ')}` : '') + ); + } + + const prResult = outcome.result; + return { + state: prResult.status, + head_branch: prResult.head_branch, + base_branch: prResult.base_branch, + head_sha: prResult.head_sha, + title: prResult.title, + repo_matches: true, + }; + } + /** * Create an open bead with the given labels, without arming the reconciler alarm. * The caller is responsible for including `gt:held` in the labels if the bead diff --git a/services/gastown/src/dos/town/actions.ts b/services/gastown/src/dos/town/actions.ts index 11ff0adbf5..ea6f42a48f 100644 --- a/services/gastown/src/dos/town/actions.ts +++ b/services/gastown/src/dos/town/actions.ts @@ -790,9 +790,18 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro const rigId = action.rig_id; if (!agentId) { + // Babysat external PRs bypass refinery code review — there's no source + // polecat to mail rework to and no source bead to reopen. The user + // adopted the PR explicitly to have it merged, not reviewed. The + // reconciler will fast-track these to in_progress via transition_bead + // instead of dispatching a refinery. + const targetBead = beadOps.getBead(sql, beadId); + if (targetBead?.type === 'merge_request' && targetBead.metadata?.babysit === true) { + return null; + } + // Need to get-or-create an agent for this bead. // Infer role from bead type: MR beads need refineries, issue beads need polecats. - const targetBead = beadOps.getBead(sql, beadId); const role = targetBead?.type === 'merge_request' ? 'refinery' : 'polecat'; try { const agent = agentOps.getOrCreateAgent(sql, role, rigId, townId); diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 8382683d48..831a5488e2 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -1629,6 +1629,40 @@ export function reconcileReviewQueue( } } + // Babysat PR fast-track: open MR beads with metadata.babysit=true + // bypass refinery code review regardless of the rig's code_review config. + // These beads already have pr_url set at creation (from slingExistingPr), + // so they transition directly to in_progress for poll_pr. + const babysatOpenMrs = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT b.${beads.columns.bead_id} + FROM ${beads} b + JOIN ${review_metadata} rm + ON rm.${review_metadata.columns.bead_id} = b.${beads.columns.bead_id} + WHERE b.${beads.columns.type} = 'merge_request' + AND b.${beads.columns.status} = 'open' + AND rm.${review_metadata.columns.pr_url} IS NOT NULL + AND json_extract(COALESCE(b.${beads.columns.metadata}, '{}'), '$.babysit') = 1 + `, + [] + ), + ]); + for (const { bead_id } of babysatOpenMrs) { + actions.push({ + type: 'transition_bead', + bead_id, + from: 'open', + to: 'in_progress', + reason: 'babysat PR — skip refinery, fast-track to poll_pr', + actor: 'system', + }); + } + // Rules 5-6: Refinery dispatch for open MR beads. // When code_review=true for a rig: dispatches for all open MR beads in that rig. // When code_review=false for a rig: only dispatches for convoy review-and-merge @@ -1735,6 +1769,7 @@ export function reconcileReviewQueue( WHERE ${beads.type} = 'merge_request' AND ${beads.status} = 'open' AND ${beads.rig_id} = ? + AND json_extract(COALESCE(${beads.metadata}, '{}'), '$.babysit') IS NOT 1 ${refineryNeededFilter} ORDER BY ${beads.created_at} ASC LIMIT 1 diff --git a/services/gastown/src/dos/town/review-queue.ts b/services/gastown/src/dos/town/review-queue.ts index db6ed8b2b4..c983602fe4 100644 --- a/services/gastown/src/dos/town/review-queue.ts +++ b/services/gastown/src/dos/town/review-queue.ts @@ -108,41 +108,23 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { }; } -export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { +function createMergeRequestBeadCore( + sql: SqlStorage, + args: { + rigId: string; + title: string; + body: string | null; + metadata: Record; + labels: string[]; + createdBy: string | null; + branch: string; + targetBranch: string; + prUrl?: string; + } +): { beadId: string } { const id = generateId(); const timestamp = now(); - // Build metadata — include pr_url if the agent already created a PR so - // the link is visible via the standard bead list endpoint. - const metadata: Record = { - source_bead_id: input.bead_id, - source_agent_id: input.agent_id, - }; - if (input.pr_url) { - metadata.pr_url = input.pr_url; - } - - // Resolve the target branch for this MR: - // - For review-then-land convoy beads → convoy's feature branch - // - For review-and-merge convoy beads → rig's default branch (land independently) - // - 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 convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; - const targetBranch = - convoyMergeMode === 'review-then-land' && convoyFeatureBranch - ? convoyFeatureBranch - : (input.default_branch ?? 'main'); - - if (convoyId) { - metadata.convoy_id = convoyId; - if (convoyFeatureBranch) { - metadata.convoy_feature_branch = convoyFeatureBranch; - } - } - - // Create the merge_request bead query( sql, /* sql */ ` @@ -159,45 +141,82 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v id, 'merge_request', 'open', - `Review: ${input.branch}`, - input.summary ?? null, - input.rig_id, + args.title, + args.body, + args.rigId, + null, null, - null, // assignee left null — refinery claims it via hookBead 'medium', - JSON.stringify(['gt:merge-request']), - JSON.stringify(metadata), - input.agent_id, // created_by records who submitted + JSON.stringify(args.labels), + JSON.stringify(args.metadata), + args.createdBy, timestamp, timestamp, null, ] ); - // Link MR bead → source bead via bead_dependencies so the DAG is queryable query( sql, /* sql */ ` - INSERT INTO ${bead_dependencies} ( - ${bead_dependencies.columns.bead_id}, - ${bead_dependencies.columns.depends_on_bead_id}, - ${bead_dependencies.columns.dependency_type} - ) VALUES (?, ?, 'tracks') + INSERT INTO ${review_metadata} ( + ${review_metadata.columns.bead_id}, ${review_metadata.columns.branch}, + ${review_metadata.columns.target_branch}, ${review_metadata.columns.merge_commit}, + ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} + ) VALUES (?, ?, ?, ?, ?, ?) `, - [id, input.bead_id] + [id, args.branch, args.targetBranch, null, args.prUrl ?? null, 0] ); - // Create the review_metadata satellite + return { beadId: id }; +} + +export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { + const metadata: Record = { + source_bead_id: input.bead_id, + source_agent_id: input.agent_id, + }; + if (input.pr_url) { + metadata.pr_url = input.pr_url; + } + + const convoyId = getConvoyForBead(sql, input.bead_id); + const convoyFeatureBranch = convoyId ? getConvoyFeatureBranch(sql, convoyId) : null; + const convoyMergeMode = convoyId ? getConvoyMergeMode(sql, convoyId) : null; + const targetBranch = + convoyMergeMode === 'review-then-land' && convoyFeatureBranch + ? convoyFeatureBranch + : (input.default_branch ?? 'main'); + + if (convoyId) { + metadata.convoy_id = convoyId; + if (convoyFeatureBranch) { + metadata.convoy_feature_branch = convoyFeatureBranch; + } + } + + const { beadId } = createMergeRequestBeadCore(sql, { + rigId: input.rig_id, + title: `Review: ${input.branch}`, + body: input.summary ?? null, + metadata, + labels: ['gt:merge-request'], + createdBy: input.agent_id, + branch: input.branch, + targetBranch, + prUrl: input.pr_url, + }); + query( sql, /* sql */ ` - INSERT INTO ${review_metadata} ( - ${review_metadata.columns.bead_id}, ${review_metadata.columns.branch}, - ${review_metadata.columns.target_branch}, ${review_metadata.columns.merge_commit}, - ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} - ) VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO ${bead_dependencies} ( + ${bead_dependencies.columns.bead_id}, + ${bead_dependencies.columns.depends_on_bead_id}, + ${bead_dependencies.columns.dependency_type} + ) VALUES (?, ?, 'tracks') `, - [id, input.branch, targetBranch, null, input.pr_url ?? null, 0] + [beadId, input.bead_id] ); logBeadEvent(sql, { @@ -209,6 +228,55 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v }); } +export function submitExternalPrToReviewQueue( + sql: SqlStorage, + args: { + rigId: string; + prUrl: string; + branch: string; + targetBranch: string; + headSha: string; + title: string; + body?: string; + forcePushAllowed: boolean; + sourceAgentId: string; + } +): { beadId: string } { + const metadata: Record = { + source_agent_id: args.sourceAgentId, + babysit: true, + head_sha: args.headSha, + force_push_allowed: args.forcePushAllowed, + }; + + const { beadId } = createMergeRequestBeadCore(sql, { + rigId: args.rigId, + title: args.title, + body: args.body ?? null, + metadata, + labels: ['gt:merge-request', 'gt:babysit'], + createdBy: args.sourceAgentId, + branch: args.branch, + targetBranch: args.targetBranch, + prUrl: args.prUrl, + }); + + logBeadEvent(sql, { + beadId, + agentId: args.sourceAgentId, + eventType: 'babysit_started', + newValue: args.prUrl, + metadata: { + branch: args.branch, + target_branch: args.targetBranch, + head_sha: args.headSha, + pr_url: args.prUrl, + }, + }); + + return { beadId }; +} + export function completeReview( sql: SqlStorage, entryId: string, @@ -1185,7 +1253,8 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me ON rig.id = b.${beads.columns.rig_id} WHERE ${bead_events.event_type} IN ( 'review_submitted', 'review_completed', 'pr_created', - 'pr_creation_failed', 'rework_requested', 'status_changed' + 'pr_creation_failed', 'rework_requested', 'status_changed', + 'babysit_started' ) AND (? IS NULL OR b.${beads.columns.rig_id} = ?) AND (? IS NULL OR ${bead_events.created_at} > ?) diff --git a/services/gastown/src/dos/town/town-scm.ts b/services/gastown/src/dos/town/town-scm.ts index a077d713d3..e83cb3b130 100644 --- a/services/gastown/src/dos/town/town-scm.ts +++ b/services/gastown/src/dos/town/town-scm.ts @@ -108,6 +108,10 @@ export async function resolveGitHubTokenString(ctx: SCMContext): Promise instrumented(c, 'POST /api/mayor/:townId/tools/sling', () => handleMayorSling(c, c.req.param())) ); +app.post('/api/mayor/:townId/tools/babysit-pr', c => + instrumented(c, 'POST /api/mayor/:townId/tools/babysit-pr', () => + handleMayorBabysitPr(c, c.req.param()) + ) +); app.post('/api/mayor/:townId/tools/sling-batch', c => instrumented(c, 'POST /api/mayor/:townId/tools/sling-batch', () => handleMayorSlingBatch(c, c.req.param()) diff --git a/services/gastown/src/handlers/mayor-tools.handler.ts b/services/gastown/src/handlers/mayor-tools.handler.ts index 6bc22f0f9b..e18e6a2c97 100644 --- a/services/gastown/src/handlers/mayor-tools.handler.ts +++ b/services/gastown/src/handlers/mayor-tools.handler.ts @@ -27,6 +27,14 @@ const MayorSlingBody = z.object({ labels: z.array(z.string()).optional(), }); +const MayorBabysitPrBody = z.object({ + rig_id: z.string().min(1), + pr_url: z.string().url(), + title: z.string().optional(), + body: z.string().optional(), + force_push_allowed: z.boolean().optional(), +}); + const MayorSlingBatchBody = z .object({ rig_id: z.string().min(1), @@ -165,6 +173,45 @@ export async function handleMayorSling(c: Context, params: { townId: return c.json(resSuccess(result), 201); } +/** + * POST /api/mayor/:townId/tools/babysit-pr + * Adopt an existing PR for the town to babysit. Creates a merge_request + * bead with babysit metadata, bypasses refinery code review, and starts + * polling the PR for feedback/conflicts/auto-merge. + */ +export async function handleMayorBabysitPr(c: Context, params: { townId: string }) { + const parsed = MayorBabysitPrBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + const rigOwned = await verifyRigBelongsToTown(c, params.townId, parsed.data.rig_id); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + + console.log( + `${HANDLER_LOG} handleMayorBabysitPr: townId=${params.townId} rigId=${parsed.data.rig_id} prUrl=${parsed.data.pr_url}` + ); + + const town = getTownDOStub(c.env, params.townId); + const result = await town.slingExistingPr({ + rigId: parsed.data.rig_id, + prUrl: parsed.data.pr_url, + title: parsed.data.title, + body: parsed.data.body, + forcePushAllowed: parsed.data.force_push_allowed, + sourceAgentId: 'mayor', + }); + + console.log(`${HANDLER_LOG} handleMayorBabysitPr: completed, beadId=${result.beadId}`); + + return c.json(resSuccess(result), 201); +} + /** * GET /api/mayor/:townId/tools/rigs * List all rigs in the town. Requires userId to route to the correct diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 5ccd605417..5a5efa646b 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -31,6 +31,8 @@ import { RpcStreamTicketOutput, RpcPtySessionOutput, RpcSlingResultOutput, + RpcBabysitPrResultOutput, + RpcPreviewPrResultOutput, RpcRigDetailOutput, RpcConvoyDetailOutput, RpcAlarmStatusOutput, @@ -891,6 +893,47 @@ export const gastownRouter = router({ }); }), + babysitPr: gastownProcedure + .input( + z.object({ + rigId: z.string().uuid(), + prUrl: z.string().url(), + title: z.string().optional(), + body: z.string().optional(), + forcePushAllowed: z.boolean().optional(), + }) + ) + .output(RpcBabysitPrResultOutput) + .mutation(async ({ ctx, input }) => { + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId); + const townStub = getTownDOStub(ctx.env, rig.town_id); + return townStub.slingExistingPr({ + rigId: rig.id, + prUrl: input.prUrl, + title: input.title, + body: input.body, + forcePushAllowed: input.forcePushAllowed, + sourceAgentId: 'system', + }); + }), + + previewPr: gastownProcedure + .input( + z.object({ + rigId: z.string().uuid(), + prUrl: z.string().url(), + }) + ) + .output(RpcPreviewPrResultOutput) + .query(async ({ ctx, input }) => { + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId); + const townStub = getTownDOStub(ctx.env, rig.town_id); + return townStub.previewPr({ + rigId: rig.id, + prUrl: input.prUrl, + }); + }), + createBead: gastownProcedure .input( z.object({ diff --git a/services/gastown/src/trpc/schemas.ts b/services/gastown/src/trpc/schemas.ts index e204f3b1bf..d23849f8cf 100644 --- a/services/gastown/src/trpc/schemas.ts +++ b/services/gastown/src/trpc/schemas.ts @@ -155,6 +155,22 @@ export const SlingResultOutput = z.object({ agent: AgentOutput, }); +// BabysitPrResult +export const BabysitPrResultOutput = z.object({ + beadId: z.string(), + warning: z.string().optional(), +}); + +// PreviewPrResult +export const PreviewPrResultOutput = z.object({ + state: z.string(), + head_branch: z.string().optional(), + base_branch: z.string().optional(), + head_sha: z.string().optional(), + title: z.string().optional(), + repo_matches: z.boolean(), +}); + // getRig enriched result export const RigDetailOutput = z.object({ id: z.string(), @@ -189,6 +205,8 @@ export const RpcPtySessionOutput = rpcSafe(PtySessionOutput); export const RpcConvoyOutput = rpcSafe(ConvoyOutput); export const RpcConvoyDetailOutput = rpcSafe(ConvoyDetailOutput); export const RpcSlingResultOutput = rpcSafe(SlingResultOutput); +export const RpcBabysitPrResultOutput = rpcSafe(BabysitPrResultOutput); +export const RpcPreviewPrResultOutput = rpcSafe(PreviewPrResultOutput); // Alarm status const AlarmStatusOutput = z.object({ diff --git a/services/gastown/src/util/platform-pr.util.ts b/services/gastown/src/util/platform-pr.util.ts index f0310321c4..dc6bb713a0 100644 --- a/services/gastown/src/util/platform-pr.util.ts +++ b/services/gastown/src/util/platform-pr.util.ts @@ -88,12 +88,28 @@ export const GitHubPRStatusSchema = z.object({ state: z.string(), merged: z.boolean().optional(), mergeable: z.boolean().nullable().optional(), - mergeable_state: z.string().optional(), // 'clean', 'dirty', 'blocked', 'unknown', 'unstable' + mergeable_state: z.string().optional(), + head: z + .object({ + ref: z.string().optional(), + sha: z.string().optional(), + }) + .optional(), + base: z + .object({ + ref: z.string().optional(), + }) + .optional(), + title: z.string().optional(), }); /** Schema for GitLab MR status responses (used by checkPRStatus). */ export const GitLabMRStatusSchema = z.object({ state: z.string(), + source_branch: z.string().optional(), + target_branch: z.string().optional(), + sha: z.string().optional(), + title: z.string().optional(), }); // -- GitHub PR creation -- diff --git a/services/gastown/test/unit/babysit-pr.test.ts b/services/gastown/test/unit/babysit-pr.test.ts new file mode 100644 index 0000000000..c0d37fb13c --- /dev/null +++ b/services/gastown/test/unit/babysit-pr.test.ts @@ -0,0 +1,571 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { checkPRStatus, type SCMContext } from '../../src/dos/town/town-scm'; +import { parseGitUrl } from '../../src/util/platform-pr.util'; +import type { TownConfig } from '../../src/types'; + +function mockSCMContext(overrides: Partial = {}): SCMContext { + return { + env: {} as SCMContext['env'], + townId: 'test-town', + getTownConfig: async () => ({}) as TownConfig, + ...overrides, + }; +} + +const GITHUB_OPEN_PR = { + state: 'open', + merged: false, + mergeable_state: 'clean', + head: { ref: 'feature/babysit', sha: 'abc1234def5678' }, + base: { ref: 'main' }, + title: 'Add babysit PR feature', +}; + +const GITHUB_MERGED_PR = { + state: 'closed', + merged: true, + mergeable_state: 'clean', + head: { ref: 'feature/merged', sha: 'mergedsha123' }, + base: { ref: 'main' }, + title: 'Already merged PR', +}; + +const GITHUB_CLOSED_PR = { + state: 'closed', + merged: false, + head: { ref: 'feature/closed', sha: 'closedsha123' }, + base: { ref: 'main' }, + title: 'Closed PR', +}; + +const GITLAB_OPEN_MR = { + state: 'opened', + source_branch: 'feature/babysit', + target_branch: 'main', + sha: 'glabc1234def', + title: 'Add babysit MR feature', +}; + +const GITLAB_MERGED_MR = { + state: 'merged', + source_branch: 'feature/merged', + target_branch: 'main', + sha: 'glmergedsha', + title: 'Already merged MR', +}; + +const GITLAB_CLOSED_MR = { + state: 'closed', + source_branch: 'feature/closed', + target_branch: 'main', + sha: 'glclosedsha', + title: 'Closed MR', +}; + +describe('checkPRStatus extended fields (head_branch, base_branch, head_sha, title)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns head_branch, base_branch, head_sha, title for open GitHub PR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_OPEN_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('open'); + expect(outcome.result.head_branch).toBe('feature/babysit'); + expect(outcome.result.base_branch).toBe('main'); + expect(outcome.result.head_sha).toBe('abc1234def5678'); + expect(outcome.result.title).toBe('Add babysit PR feature'); + } + }); + + it('returns head_branch, base_branch, head_sha, title for merged GitHub PR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_MERGED_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('merged'); + expect(outcome.result.head_branch).toBe('feature/merged'); + expect(outcome.result.base_branch).toBe('main'); + expect(outcome.result.head_sha).toBe('mergedsha123'); + expect(outcome.result.title).toBe('Already merged PR'); + } + }); + + it('returns head_branch, base_branch, head_sha, title for closed GitHub PR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_CLOSED_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('closed'); + expect(outcome.result.head_branch).toBe('feature/closed'); + expect(outcome.result.base_branch).toBe('main'); + expect(outcome.result.head_sha).toBe('closedsha123'); + } + }); + + it('returns head_branch, base_branch, head_sha, title for open GitLab MR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITLAB_OPEN_MR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { gitlab_token: 'glpat_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://gitlab.com/group/project/-/merge_requests/5'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('open'); + expect(outcome.result.head_branch).toBe('feature/babysit'); + expect(outcome.result.base_branch).toBe('main'); + expect(outcome.result.head_sha).toBe('glabc1234def'); + expect(outcome.result.title).toBe('Add babysit MR feature'); + } + }); + + it('returns extended fields for merged GitLab MR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITLAB_MERGED_MR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { gitlab_token: 'glpat_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://gitlab.com/group/project/-/merge_requests/5'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('merged'); + expect(outcome.result.head_branch).toBe('feature/merged'); + expect(outcome.result.base_branch).toBe('main'); + } + }); + + it('returns extended fields for closed GitLab MR', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITLAB_CLOSED_MR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { gitlab_token: 'glpat_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://gitlab.com/group/project/-/merge_requests/5'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('closed'); + expect(outcome.result.head_branch).toBe('feature/closed'); + } + }); + + it('returns undefined extended fields when API omits them', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ state: 'open', merged: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('open'); + expect(outcome.result.head_branch).toBeUndefined(); + expect(outcome.result.base_branch).toBeUndefined(); + expect(outcome.result.head_sha).toBeUndefined(); + expect(outcome.result.title).toBeUndefined(); + } + }); + + it('SCM API failure propagates with structured failureKind', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_bad' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(false); + if (!outcome.ok && outcome.error.kind === 'http_error') { + expect(outcome.error.status).toBe(401); + expect(outcome.error.provider).toBe('github'); + expect(outcome.error.transient).toBe(false); + } + }); + + it('no_token error propagates with resolution chain', async () => { + const ctx = mockSCMContext({ + getTownConfig: async () => ({}) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(false); + if (!outcome.ok && outcome.error.kind === 'no_token') { + expect(outcome.error.provider).toBe('github'); + expect(outcome.error.resolutionChain.length).toBeGreaterThan(0); + } + }); +}); + +describe('slingExistingPr repo validation', () => { + it('matches GitHub PR URL to rig git_url', () => { + const rigCoords = parseGitUrl('https://github.com/Kilo-Org/cloud'); + expect(rigCoords).toEqual({ + platform: 'github', + owner: 'Kilo-Org', + repo: 'cloud', + }); + + const prUrlMatch = 'https://github.com/Kilo-Org/cloud/pull/42'.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/ + ); + expect(prUrlMatch).not.toBeNull(); + expect(prUrlMatch![1]).toBe('Kilo-Org'); + expect(prUrlMatch![2]).toBe('cloud'); + expect(prUrlMatch![1]).toBe(rigCoords!.owner); + expect(prUrlMatch![2]).toBe(rigCoords!.repo); + }); + + it('detects repo mismatch between GitHub PR URL and rig', () => { + const rigCoords = parseGitUrl('https://github.com/Kilo-Org/cloud'); + const prUrlMatch = 'https://github.com/Other-Org/cloud/pull/42'.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/ + ); + expect(prUrlMatch).not.toBeNull(); + expect(prUrlMatch![1]).toBe('Other-Org'); + expect(prUrlMatch![1]).not.toBe(rigCoords!.owner); + }); + + it('detects platform mismatch (GitHub rig vs GitLab PR)', () => { + const rigCoords = parseGitUrl('https://github.com/Kilo-Org/cloud'); + expect(rigCoords!.platform).toBe('github'); + + const glMatch = 'https://gitlab.com/Kilo-Org/cloud/-/merge_requests/7'.match( + /^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/ + ); + expect(glMatch).not.toBeNull(); + }); + + it('matches GitLab MR URL to rig git_url with gitlab_instance_url', () => { + const rigCoords = parseGitUrl( + 'https://gitlab.mycompany.com/group/project', + 'https://gitlab.mycompany.com' + ); + expect(rigCoords).toEqual({ + platform: 'gitlab', + owner: 'group', + repo: 'project', + }); + + const glMatch = 'https://gitlab.mycompany.com/group/project/-/merge_requests/7'.match( + /^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/ + ); + expect(glMatch).not.toBeNull(); + const fullPath = glMatch![2]; + const lastSlash = fullPath.lastIndexOf('/'); + const prOwner = lastSlash > 0 ? fullPath.slice(0, lastSlash) : fullPath; + const prRepo = lastSlash > 0 ? fullPath.slice(lastSlash + 1) : ''; + expect(prOwner).toBe(rigCoords!.owner); + expect(prRepo).toBe(rigCoords!.repo); + }); + + it('rejects non-GitHub/GitLab PR URLs', () => { + const prUrl = 'https://example.com/some/pr/1'; + const ghMatch = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + const glMatch = prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); + expect(ghMatch).toBeNull(); + expect(glMatch).toBeNull(); + }); + + it('rejects non-GitLab host with /-/merge_requests/ pattern (e.g. Bitbucket)', () => { + const prUrl = 'https://bitbucket.org/group/project/-/merge_requests/1'; + const ghMatch = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + expect(ghMatch).toBeNull(); + const glMatch = prUrl.match(/^(https:\/\/[^/]+)\/(.+)\/-\/merge_requests\/(\d+)/); + expect(glMatch).not.toBeNull(); + const glHost = new URL(glMatch![1]).hostname; + expect(glHost).toBe('bitbucket.org'); + expect(glHost !== 'gitlab.com' && glHost !== null).toBe(true); + }); +}); + +describe('slingExistingPr state validation via checkPRStatus', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('checkPRStatus returns merged → slingExistingPr would refuse', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_MERGED_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('merged'); + } + }); + + it('checkPRStatus returns closed → slingExistingPr would refuse', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_CLOSED_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('closed'); + } + }); + + it('checkPRStatus returns open with full metadata → slingExistingPr would proceed', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(GITHUB_OPEN_PR), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const ctx = mockSCMContext({ + getTownConfig: async () => ({ git_auth: { github_token: 'ghp_test' } }) as TownConfig, + }); + const outcome = await checkPRStatus(ctx, 'https://github.com/owner/repo/pull/1'); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.result.status).toBe('open'); + expect(outcome.result.head_branch).toBe('feature/babysit'); + expect(outcome.result.base_branch).toBe('main'); + expect(outcome.result.head_sha).toBe('abc1234def5678'); + } + }); +}); + +describe('forcePushAllowed default', () => { + it('defaults to false when not specified', () => { + const input = { forcePushAllowed: undefined }; + const resolved = input.forcePushAllowed ?? false; + expect(resolved).toBe(false); + }); + + it('persists true when explicitly set', () => { + const input = { forcePushAllowed: true }; + const resolved = input.forcePushAllowed ?? false; + expect(resolved).toBe(true); + }); + + it('persists false when explicitly set', () => { + const input = { forcePushAllowed: false }; + const resolved = input.forcePushAllowed ?? false; + expect(resolved).toBe(false); + }); +}); + +describe('submitExternalPrToReviewQueue metadata shape', () => { + it('builds correct metadata for babysit bead', () => { + const args = { + sourceAgentId: 'mayor', + headSha: 'abc1234', + forcePushAllowed: false, + }; + const metadata: Record = { + source_agent_id: args.sourceAgentId, + babysit: true, + head_sha: args.headSha, + force_push_allowed: args.forcePushAllowed, + }; + expect(metadata.babysit).toBe(true); + expect(metadata.head_sha).toBe('abc1234'); + expect(metadata.force_push_allowed).toBe(false); + expect(metadata.source_agent_id).toBe('mayor'); + expect(metadata).not.toHaveProperty('source_bead_id'); + }); + + it('includes force_push_allowed: true when authorized', () => { + const args = { + sourceAgentId: 'system', + headSha: 'def5678', + forcePushAllowed: true, + }; + const metadata: Record = { + source_agent_id: args.sourceAgentId, + babysit: true, + head_sha: args.headSha, + force_push_allowed: args.forcePushAllowed, + }; + expect(metadata.force_push_allowed).toBe(true); + }); + + it('uses gt:merge-request and gt:babysit labels', () => { + const labels = ['gt:merge-request', 'gt:babysit']; + expect(labels).toContain('gt:merge-request'); + expect(labels).toContain('gt:babysit'); + }); +}); + +describe('babysitPr tRPC mutation input validation', () => { + const babysitPrInputSchema = { + rigId: 'string-uuid', + prUrl: 'url', + title: 'string-optional', + body: 'string-optional', + forcePushAllowed: 'boolean-optional', + }; + + it('requires rigId and prUrl', () => { + expect(babysitPrInputSchema.rigId).toBeDefined(); + expect(babysitPrInputSchema.prUrl).toBeDefined(); + }); + + it('title, body, forcePushAllowed are optional', () => { + expect(babysitPrInputSchema.title).toBeDefined(); + expect(babysitPrInputSchema.body).toBeDefined(); + expect(babysitPrInputSchema.forcePushAllowed).toBeDefined(); + }); +}); + +describe('previewPr tRPC query output', () => { + it('returns repo_matches: false on repo mismatch without throwing', () => { + const result = { + state: 'unknown', + repo_matches: false, + }; + expect(result.repo_matches).toBe(false); + expect(result.state).toBe('unknown'); + }); + + it('returns full PR metadata on repo match', () => { + const result = { + state: 'open', + head_branch: 'feature/x', + base_branch: 'main', + head_sha: 'abc1234', + title: 'My PR', + repo_matches: true, + }; + expect(result.repo_matches).toBe(true); + expect(result.head_branch).toBe('feature/x'); + expect(result.base_branch).toBe('main'); + expect(result.head_sha).toBe('abc1234'); + expect(result.title).toBe('My PR'); + }); +}); + +describe('mayor-tools babysit-pr handler input schema', () => { + it('validates required fields', () => { + const body = { + rig_id: 'rig-1', + pr_url: 'https://github.com/owner/repo/pull/1', + }; + expect(body.rig_id).toBeTruthy(); + expect(body.pr_url).toMatch(/^https:\/\//); + }); + + it('sourceAgentId is forced to mayor', () => { + const sourceAgentId = 'mayor'; + expect(sourceAgentId).toBe('mayor'); + }); +}); + +describe('refinery bypass for babysat beads', () => { + it('dispatch_agent returns null for babysit merge_request beads', () => { + const targetBead = { + type: 'merge_request', + metadata: { babysit: true }, + }; + const shouldBypass = + targetBead.type === 'merge_request' && targetBead.metadata?.babysit === true; + expect(shouldBypass).toBe(true); + }); + + it('dispatch_agent does NOT bypass for non-babysit merge_request beads', () => { + const targetBead = { + type: 'merge_request', + metadata: {}, + }; + const shouldBypass = + targetBead.type === 'merge_request' && targetBead.metadata?.babysit === true; + expect(shouldBypass).toBe(false); + }); + + it('dispatch_agent does NOT bypass for issue beads', () => { + const targetBead = { + type: 'issue', + metadata: {}, + }; + const shouldBypass = + targetBead.type === 'merge_request' && targetBead.metadata?.babysit === true; + expect(shouldBypass).toBe(false); + }); +}); + +describe('reconciler babysit fast-track', () => { + it('babysat beads fast-track regardless of code_review config', () => { + const rigCodeReview = true; + const beadIsBabysat = true; + const shouldFastTrack = beadIsBabysat; + expect(shouldFastTrack).toBe(true); + expect(rigCodeReview).toBe(true); + }); + + it('babysat beads with pr_url transition to in_progress', () => { + const action = { + type: 'transition_bead' as const, + bead_id: 'test-bead', + from: 'open', + to: 'in_progress', + reason: 'babysat PR — skip refinery, fast-track to poll_pr', + actor: 'system', + }; + expect(action.type).toBe('transition_bead'); + expect(action.from).toBe('open'); + expect(action.to).toBe('in_progress'); + expect(action.reason).toContain('babysat'); + }); + + it('non-babysat MR beads on code_review=true rig do NOT fast-track', () => { + const rigCodeReview = true; + const beadIsBabysat = false; + const shouldFastTrack = beadIsBabysat; + expect(shouldFastTrack).toBe(false); + expect(rigCodeReview).toBe(true); + }); +}); From df48f9f661b709da1f733de23fe13b52afd37119 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 29 May 2026 10:48:41 -0500 Subject: [PATCH 12/15] feat(web): SlingDialog babysit-PR mode (chunk 3) (#3583) * feat(gastown): Town DO slingExistingPr method (babysit PR feature, chunk 0) (#3576) * feat(gastown): Town DO slingExistingPr method (babysit PR feature, chunk 0) * fix(gastown): address PR review feedback on slingExistingPr - Remove async from submitExternalPrToReviewQueue (no awaits inside) - Add GitLab host validation in slingExistingPr URL parsing to prevent non-GitLab URLs (e.g. Bitbucket) from matching the GitLab regex - Replace vacuous state validation tests with meaningful checkPRStatus integration tests that exercise the actual status resolution path --------- Co-authored-by: John Fawcett * feat(gastown): tRPC + HTTP for babysit PR (chunk 1) Add gastown.babysitPr tRPC mutation, gastown.previewPr tRPC query, mayor-tools babysit-pr HTTP route, refinery bypass for babysat beads, and reconciler fast-track extension for babysat MR beads. * feat(web): BeadPanel babysit badge + metadata (chunk 4) (#3581) feat(web): BeadPanel babysit badge + metadata surfacing (chunk 4) - Add 'Babysat external PR' badge (cyan) when gt:babysit label present - Hide source bead link for babysit beads (no source_bead_id) - Add babysit metadata panel: head_sha (7-char, linked), force_push_allowed (Lock/Unlock icons), babysit_started_at (relative time) - Add event formatters for babysit_started, pr_feedback_detected, pr_conflict_detected, pr_auto_merge, pr_status_changed - Mirror babysit badge + metadata in BeadInspectorDashboard - Extract buildRelatedBeads and eventDescription into separate files for testability - Add unit tests for eventDescription and buildRelatedBeads babysit logic Co-authored-by: John Fawcett * feat(gastown): gt_babysit_pr mayor tool + force-push gate (chunk 2) (#3582) * feat(gastown): gt_babysit_pr mayor tool + force-push gate (chunk 2) Add gt_babysit_pr mayor tool registration and client method, update mayor and polecat system prompts with babysit PR guidance and force-push policy, surface force_push_allowed in polecat prime context, and add corresponding unit tests. * fix(gastown): address PR review warnings on babysit PR feature - Rewrite PR Conflict Resolution Workflow in polecat-system.prompt.ts to choose strategy upfront (rebase vs merge) based on force_push_allowed, instead of rebase-then-abort which discarded conflict resolution work. - Extract resolveForcePushAllowed() from agents.ts prime() so tests exercise the actual function instead of inlining the logic. --------- Co-authored-by: John Fawcett * feat(web): SlingDialog babysit-PR mode (chunk 3) Add tabbed SlingDialog with 'Sling work' and 'Babysit existing PR' tabs. The Babysit tab includes PR URL input with debounced preview, repo mismatch and closed PR validation, title/body overrides, force-push checkbox, and calls babysitPr mutation on submit. Also updates generated tRPC type declarations for the new previewPr and babysitPr procedures. * fix: address review feedback on PR #3583 - Fix CRITICAL: move isBabysit declaration above relatedBeads in BeadPanel.tsx (temporal dead zone caused ReferenceError on babysit bead renders) - Fix WARNING: wire up userEditedTitle/userEditedBody in BabysitPrPanel.tsx (auto-populate title/body from preview unless user has manually edited) - Fix WARNING: add babysit_started_at to bead metadata in submitExternalPrToReviewQueue so UI timestamps render correctly * fix(web): move defaultTitle/defaultBody after preview declaration to fix TDZ bug The defaultTitle and defaultBody const declarations referenced preview before it was declared (temporal dead zone), causing ReferenceError on every render. Moved them after the preview declaration. --------- Co-authored-by: John Fawcett --- .../rigs/[rigId]/RigDetailPageClient.tsx | 13 + .../beads/[beadId]/BeadInspectorDashboard.tsx | 56 ++- .../components/gastown/ActivityFeed.test.ts | 95 ++++ .../src/components/gastown/ActivityFeed.tsx | 74 +-- .../components/gastown/BabysitPrPanel.test.ts | 245 ++++++++++ .../src/components/gastown/BabysitPrPanel.tsx | 289 ++++++++++++ .../src/components/gastown/SlingDialog.tsx | 166 ++++--- .../gastown/drawer-panels/BeadPanel.test.ts | 94 ++++ .../gastown/drawer-panels/BeadPanel.tsx | 206 +++----- .../drawer-panels/buildRelatedBeads.ts | 137 ++++++ .../components/gastown/eventDescription.ts | 72 +++ apps/web/src/lib/gastown/types/router.d.ts | 58 +++ apps/web/src/lib/gastown/types/schemas.d.ts | 42 ++ .../gastown/container/plugin/client.test.ts | 46 ++ services/gastown/container/plugin/client.ts | 14 + .../container/plugin/mayor-tools.test.ts | 62 +++ .../gastown/container/plugin/mayor-tools.ts | 46 ++ services/gastown/container/plugin/types.ts | 34 ++ services/gastown/src/dos/Town.do.ts | 3 + services/gastown/src/dos/town/agents.ts | 15 + services/gastown/src/dos/town/review-queue.ts | 1 + .../src/prompts/mayor-system.prompt.ts | 28 ++ .../src/prompts/polecat-system.prompt.ts | 54 ++- services/gastown/src/types.ts | 4 + services/gastown/test/unit/babysit-pr.test.ts | 63 +++ .../test/unit/sling-existing-pr.test.ts | 445 ++++++++++++++++++ 26 files changed, 2073 insertions(+), 289 deletions(-) create mode 100644 apps/web/src/components/gastown/ActivityFeed.test.ts create mode 100644 apps/web/src/components/gastown/BabysitPrPanel.test.ts create mode 100644 apps/web/src/components/gastown/BabysitPrPanel.tsx create mode 100644 apps/web/src/components/gastown/drawer-panels/BeadPanel.test.ts create mode 100644 apps/web/src/components/gastown/drawer-panels/buildRelatedBeads.ts create mode 100644 apps/web/src/components/gastown/eventDescription.ts create mode 100644 services/gastown/test/unit/sling-existing-pr.test.ts diff --git a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx index f7917eec57..807b329b7a 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/RigDetailPageClient.tsx @@ -11,9 +11,11 @@ import { BeadBoard } from '@/components/gastown/BeadBoard'; import { AgentCard } from '@/components/gastown/AgentCard'; import { ConvoyTimeline } from '@/components/gastown/ConvoyTimeline'; import { CreateBeadDrawer } from '@/components/gastown/CreateBeadDrawer'; +import { SlingDialog } from '@/components/gastown/SlingDialog'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; import { Plus, + Zap, GitBranch, Hexagon, Bot, @@ -42,6 +44,7 @@ export function RigDetailPageClient({ const trpc = useGastownTRPC(); const confirm = useConfirm(); const [isCreateBeadOpen, setIsCreateBeadOpen] = useState(false); + const [isSlingOpen, setIsSlingOpen] = useState(false); const [convoysCollapsed, setConvoysCollapsed] = useState(false); const { open: openDrawer } = useDrawerStack(); @@ -165,6 +168,15 @@ export function RigDetailPageClient({ > +