From ba2390f603c14b97277947e4e5dc94837e71b827 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:32:32 -0500 Subject: [PATCH 1/9] 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 a6a088c628..e0860c6dc5 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -948,105 +948,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, @@ -1156,19 +1068,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 { @@ -2835,15 +2734,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, @@ -3045,13 +2935,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( @@ -4492,10 +4375,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 705792eb89..df2941749f 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 84c31be162..8692bb696f 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -109,14 +109,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. */ @@ -134,17 +134,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 { @@ -158,9 +147,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; @@ -180,12 +169,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, @@ -200,17 +188,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 90a39936b81865910ba1687d21b0ac23284c475a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:45:41 -0500 Subject: [PATCH 2/9] 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 761feefbaa..67d5adf694 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -41,6 +41,11 @@ import type { 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()); @@ -95,6 +100,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'], @@ -176,6 +183,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( @@ -260,11 +268,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. @@ -276,6 +285,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', { @@ -303,9 +315,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 }); @@ -338,6 +349,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}` @@ -671,10 +686,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); @@ -704,10 +723,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 e0860c6dc5..3d96e78bfe 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -4814,7 +4814,13 @@ export class TownDO extends DurableObject { // 5s truncation of a plain /health ping. For already-warm containers // this is a cheap RPC that returns { coldStart: false }. 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 df2941749f..412ce8efc7 100644 --- a/services/gastown/src/dos/TownContainer.do.ts +++ b/services/gastown/src/dos/TownContainer.do.ts @@ -60,14 +60,25 @@ export class TownContainerDO extends Container { * /health ping — gives an accurate cold-start measurement without being * capped by an arbitrary client-side timeout. */ - async warmUp(): Promise<{ coldStart: boolean; durationMs: number }> { + async warmUp(params: { + townId: string; + containerToken?: string; + }): Promise<{ coldStart: boolean; durationMs: number }> { const state = await this.getState(); const alreadyHealthy = this.ctx.container?.running === true && state.status === 'healthy'; if (alreadyHealthy) { 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 8692bb696f..f48d85da6d 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -108,6 +108,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 @@ -125,13 +138,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. @@ -141,7 +152,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}`); @@ -180,12 +191,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 @@ -193,7 +203,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 3e6f98ce945ec5f59b29b06111162794592250b7 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 19 May 2026 20:46:20 -0500 Subject: [PATCH 3/9] 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 8553c88b8a59faf61f97427dad20b4643d24cb55 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 20 May 2026 13:00:04 -0500 Subject: [PATCH 4/9] 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 | 83 +++++++++++++++++-- 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, 163 insertions(+), 33 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e758f3b7b6..641fb5d303 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1721,7 +1721,7 @@ importers: version: 7.0.0-dev.20260514.1 jest: specifier: 30.3.0 - version: 30.3.0(@types/node@24.12.4)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1851,8 +1851,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.18 version: 4.12.18 @@ -4838,12 +4838,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==} + '@lexical/clipboard@0.35.0': resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==} @@ -7128,6 +7128,7 @@ packages: '@rocicorp/resolver@1.0.2': resolution: {integrity: sha512-TfjMTQp9cNNqNtHFfa+XHEGdA7NnmDRu+ZJH4YF3dso0Xk/b9DMhg/sl+b6CR4ThFZArXXDsG1j8Mwl34wcOZQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + deprecated: Use Promise.withResolvers instead '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} @@ -18478,7 +18479,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.4 miniflare: 4.20260508.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) wrangler: 4.90.1(@cloudflare/workers-types@4.20260511.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 transitivePeerDependencies: @@ -19955,11 +19956,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 @@ -28690,6 +28691,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.4): dependencies: '@babel/core': 7.29.0 @@ -28782,6 +28802,38 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.5.2 + esbuild-register: 3.6.0(esbuild@0.27.4) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -29309,6 +29361,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.2)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} diff --git a/services/gastown/container/Dockerfile b/services/gastown/container/Dockerfile index a0db8ddc00..0f1864ae01 100644 --- a/services/gastown/container/Dockerfile +++ b/services/gastown/container/Dockerfile @@ -72,7 +72,7 @@ RUN git lfs install --system # 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 0b5ecf53ff..ab3ada9df0 100644 --- a/services/gastown/container/Dockerfile.dev +++ b/services/gastown/container/Dockerfile.dev @@ -71,7 +71,7 @@ RUN git lfs install --system # 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 4d35c350c0..426fe81883 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'; @@ -2767,6 +2768,8 @@ function buildPrewarmEnv(ctx: MayorPrewarmContext, townId: string): Record Date: Wed, 20 May 2026 13:32:08 -0500 Subject: [PATCH 5/9] 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 426fe81883..923f8b587d 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, @@ -31,6 +31,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 }; @@ -2904,67 +2913,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 f48d85da6d..d78f9e6e16 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -138,7 +138,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; } @@ -147,6 +154,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', @@ -157,12 +165,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; } @@ -375,8 +398,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, { @@ -437,6 +476,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 5bd311e2996bfc64b6616b644dcb1328f3edf5d6 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 21 May 2026 10:29:19 -0500 Subject: [PATCH 6/9] 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 8698f9e41f..a682f9fc30 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -37,7 +37,7 @@ "class_name": "TownContainerDO", "image": "./container/Dockerfile", "instance_type": "standard-4", - "max_instances": 500, + "max_instances": 700, }, ], From 8d648c54b33cb205741530a2155c8cef9f2b1352 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 22 May 2026 10:36:05 -0500 Subject: [PATCH 7/9] 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 | 5 +++- .../cloud-agent-next/ChatSidebar.tsx | 4 +++- .../cloud-agent-next/CloudSidebarLayout.tsx | 3 ++- .../container/src/process-manager.test.ts | 24 ++++++++----------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index 2437e892e1..7b164a5cbc 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -7,7 +7,7 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; -const PLATFORM_FILTERS = ['cloud-agent', 'extension', 'cli', 'slack', 'other'] as const; +const PLATFORM_FILTERS = ['cloud-agent', 'extension', 'gastown', 'cli', 'slack', 'other'] as const; const chipScrollContentStyle = { paddingHorizontal: 22, paddingVertical: 8, gap: 8 }; export type ProjectFilterOption = { @@ -57,6 +57,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 df573cf090..9e6d382d2f 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -270,7 +270,7 @@ function SessionRow({ ); } -const PLATFORM_FILTERS = ['cloud-agent', 'extension', 'cli', 'slack', 'other'] as const; +const PLATFORM_FILTERS = ['cloud-agent', 'extension', 'gastown', 'cli', 'slack', 'other'] as const; function platformFilterLabel(p: string): string { switch (p) { @@ -284,6 +284,8 @@ function platformFilterLabel(p: string): string { return 'Slack'; 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 50cd5c7098..461eaa2a21 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -56,13 +56,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 8e60feba2e085c06e11466a076c9934243f2fe03 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 22 May 2026 11:04:58 -0500 Subject: [PATCH 8/9] 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 461eaa2a21..b85df1d4ee 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -56,7 +56,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 174b4267dc..427ea00e5b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,6 +43,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 c8d2aa918c85cf96ff634eb2e399cde16fc0bad5 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 22 May 2026 11:06:31 -0600 Subject: [PATCH 9/9] fix(gastown): tag structured logs with town ids --- services/gastown/container/plugin/index.ts | 10 +- .../gastown/container/src/agent-runner.ts | 1 + .../gastown/container/src/control-server.ts | 4 + services/gastown/container/src/main.ts | 1 + .../gastown/container/src/process-manager.ts | 33 ++++++- .../gastown/container/src/token-refresh.ts | 8 +- services/gastown/src/dos/Town.do.ts | 3 +- .../src/dos/town/container-idle-stop.ts | 2 + services/gastown/src/gastown.worker.ts | 61 +++++++++--- services/gastown/src/trpc/init.ts | 15 ++- services/gastown/src/trpc/router.ts | 97 +++++++++++++------ 11 files changed, 182 insertions(+), 53 deletions(-) diff --git a/services/gastown/container/plugin/index.ts b/services/gastown/container/plugin/index.ts index d9f495ea15..51184bfcb2 100644 --- a/services/gastown/container/plugin/index.ts +++ b/services/gastown/container/plugin/index.ts @@ -105,9 +105,17 @@ export const GastownPlugin: Plugin = async ({ client }) => { // Best-effort logging — never let telemetry failures break tool execution async function log(level: 'info' | 'error', message: string) { console.log(`${SERVICE} ${level}: ${message}`); + const townId = process.env.GASTOWN_TOWN_ID; try { - await client.app.log({ body: { service: SERVICE, level, message } }); + await client.app.log({ + body: { + service: SERVICE, + level, + message, + ...(townId ? { extra: { townId } } : {}), + }, + }); } catch { // Swallow — logging is non-critical } diff --git a/services/gastown/container/src/agent-runner.ts b/services/gastown/container/src/agent-runner.ts index 17d226f1a8..88db516714 100644 --- a/services/gastown/container/src/agent-runner.ts +++ b/services/gastown/container/src/agent-runner.ts @@ -580,6 +580,7 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise { const activeAgents = listAgents().filter(a => a.status === 'running' || a.status === 'starting'); log.info('refresh_token.received', { + townId: process.env.GASTOWN_TOWN_ID ?? null, agentCount: activeAgents.length, agentIds: activeAgents.map(a => a.agentId), }); @@ -299,6 +300,7 @@ app.post('/refresh-token', async c => { const results = await refreshTokenForAllAgents(); const successCount = results.filter(r => r.success).length; log.info('refresh_token.completed', { + townId: process.env.GASTOWN_TOWN_ID ?? null, agentCount: results.length, successCount, failureCount: results.length - successCount, @@ -829,6 +831,7 @@ app.post('/agents/:agentId/pty', async c => { const reuseAgent = getAgentStatus(agentId); if (reuseAgent) { log.info('agent.pty_connected', { + townId: reuseAgent.townId, agentId, containerUptimeMs: getUptime(), agentUptimeMs: Date.now() - new Date(reuseAgent.startedAt).getTime(), @@ -889,6 +892,7 @@ app.post('/agents/:agentId/pty', async c => { ); if (createResp.ok) { log.info('agent.pty_connected', { + townId: agent.townId, agentId, containerUptimeMs: getUptime(), agentUptimeMs: Date.now() - new Date(agent.startedAt).getTime(), diff --git a/services/gastown/container/src/main.ts b/services/gastown/container/src/main.ts index 07b0ecb230..225860942e 100644 --- a/services/gastown/container/src/main.ts +++ b/services/gastown/container/src/main.ts @@ -76,6 +76,7 @@ setInterval(() => { }); } catch (err) { log.warn('container.memory_usage_failed', { + townId: townIdForLogs(), error: err instanceof Error ? err.message : String(err), }); } diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 923f8b587d..7def77d2d4 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -71,6 +71,7 @@ const IDLE_TIMER_IGNORE_EVENTS = new Set([ let nextPort = 4096; const startTime = Date.now(); +const TOWN_ID = process.env.GASTOWN_TOWN_ID ?? null; // Set to true when drainAll() starts — prevents new agent starts and // lets the drain loop nudge agents that transition to running mid-drain. @@ -163,6 +164,7 @@ function markMayorReadyOnce(): void { if (mayorReadyAt !== null) return; mayorReadyAt = new Date().toISOString(); log.info('mayor.ready', { + townId: TOWN_ID, containerUptimeMs: getUptime(), mayorReadyAt, }); @@ -405,6 +407,7 @@ async function saveDbSnapshot( ); log.error('mayor.snapshot_failed', { event: 'mayor.snapshot_failed', + townId, agentId, role, durationMs: Date.now() - t0, @@ -430,6 +433,7 @@ async function saveDbSnapshot( console.warn(`${MANAGER_LOG} Failed to save DB snapshot for ${agentId}: ${resp.status}`); log.error('mayor.snapshot_failed', { event: 'mayor.snapshot_failed', + townId, agentId, role, durationMs: Date.now() - t0, @@ -444,6 +448,7 @@ async function saveDbSnapshot( ); log.info('mayor.snapshot_saved', { event: 'mayor.snapshot_saved', + townId, agentId, role, durationMs: Date.now() - t0, @@ -458,6 +463,7 @@ async function saveDbSnapshot( console.warn(`${MANAGER_LOG} DB snapshot save failed for agent ${agentId}:`, err); log.error('mayor.snapshot_failed', { event: 'mayor.snapshot_failed', + townId, agentId, role, durationMs: Date.now() - t0, @@ -973,6 +979,7 @@ async function subscribeToEvents( const exitAgent = () => { if (agent.status !== 'running') return; log.info('agent.exit', { + townId: agent.townId, agentId: agent.agentId, name: agent.name, reason: 'completed', @@ -1073,6 +1080,7 @@ async function subscribeToEvents( } catch (err) { if (!controller.signal.aborted) { log.error('agent.stream_error', { + townId: agent.townId, agentId: agent.agentId, error: err instanceof Error ? err.message : String(err), }); @@ -1197,6 +1205,7 @@ async function startAgentImpl( } const tDbDone = Date.now(); log.info('agent.startup_phase', { + townId: request.townId, agentId: request.agentId, phase: 'db_hydrated', elapsedMs: tDbDone - t0, @@ -1214,6 +1223,7 @@ async function startAgentImpl( agent.serverPort = port; const tSdkDone = Date.now(); log.info('agent.startup_phase', { + townId: request.townId, agentId: request.agentId, phase: 'sdk_ready', elapsedMs: tSdkDone - t0, @@ -1276,6 +1286,7 @@ async function startAgentImpl( agent.sessionId = sessionId; const tSessionDone = Date.now(); log.info('agent.startup_phase', { + townId: request.townId, agentId: request.agentId, phase: 'session_created', elapsedMs: tSessionDone - t0, @@ -1352,6 +1363,7 @@ async function startAgentImpl( agent.messageCount = 1; log.info('agent.start', { + townId: request.townId, agentId: request.agentId, role: request.role, name: request.name, @@ -1360,6 +1372,7 @@ async function startAgentImpl( }); log.info('agent.startup_complete', { + townId: request.townId, agentId: request.agentId, totalMs: Date.now() - t0, containerUptimeMs: getUptime(), @@ -1462,6 +1475,7 @@ export async function stopAgent(agentId: string): Promise { } } catch (err) { log.warn('agent.stop_failed', { + townId: agent.townId, agentId, error: err instanceof Error ? err.message : String(err), }); @@ -1469,7 +1483,12 @@ export async function stopAgent(agentId: string): Promise { agent.status = 'exited'; agent.exitReason = 'stopped'; - log.info('agent.exit', { agentId, reason: 'stopped', exitReason: 'stopped' }); + log.info('agent.exit', { + townId: agent.townId, + agentId, + reason: 'stopped', + exitReason: 'stopped', + }); broadcastEvent(agentId, 'agent.exited', { reason: 'stopped' }); syncRegistry(); @@ -1504,6 +1523,7 @@ export async function sendMessage(agentId: string, prompt: string): Promise { @@ -1926,11 +1952,13 @@ export async function refreshTokenForAllAgents(): Promise< orphan.server.close(); } catch (closeErr) { log.warn('refresh_token.orphan_close_failed', { + townId: reapTownId, agentId: reapAgentId, error: closeErr instanceof Error ? closeErr.message : String(closeErr), }); } log.warn('refresh_token.orphan_reaped', { + townId: reapTownId, agentId: reapAgentId, orphanPort, }); @@ -1942,6 +1970,7 @@ export async function refreshTokenForAllAgents(): Promise< ); } log.error('refresh_token.agent_restarted', { + townId: agent.townId, agentId: agent.agentId, role: agent.role, name: agent.name, @@ -2537,6 +2566,7 @@ export async function drainAll(): Promise { console.error(`${DRAIN_LOG} snapshot timeout/failure for ${agent.agentId}:`, err); log.error('mayor.snapshot_failed', { event: 'mayor.snapshot_failed', + townId: agent.townId, agentId: agent.agentId, role: agent.role, error: err instanceof Error ? err.message : String(err), @@ -2613,6 +2643,7 @@ export async function stopAll(): Promise { console.error(`[stop-all] snapshot timeout/failure for ${agent.agentId}:`, err); log.error('mayor.snapshot_failed', { event: 'mayor.snapshot_failed', + townId: agent.townId, agentId: agent.agentId, role: agent.role, error: err instanceof Error ? err.message : String(err), diff --git a/services/gastown/container/src/token-refresh.ts b/services/gastown/container/src/token-refresh.ts index 5d7d74b3ac..df0877814c 100644 --- a/services/gastown/container/src/token-refresh.ts +++ b/services/gastown/container/src/token-refresh.ts @@ -54,6 +54,7 @@ export async function fetchFreshContainerToken(): Promise { if (!apiUrl || !townId || !currentToken) { log.warn('token_refresh.skipped_missing_env', { + townId: townId ?? null, hasApiUrl: !!apiUrl, hasTownId: !!townId, hasCurrentToken: !!currentToken, @@ -75,6 +76,7 @@ export async function fetchFreshContainerToken(): Promise { if (!resp.ok) { const text = await resp.text().catch(() => ''); log.warn('token_refresh.fetch_failed', { + townId, status: resp.status, durationMs: Date.now() - t0, body: text.slice(0, 200), @@ -87,14 +89,15 @@ export async function fetchFreshContainerToken(): Promise { ? (body as { data?: { token?: unknown } }).data?.token : undefined; if (typeof token !== 'string' || token.length === 0) { - log.warn('token_refresh.invalid_response', { durationMs: Date.now() - t0 }); + log.warn('token_refresh.invalid_response', { townId, durationMs: Date.now() - t0 }); return null; } process.env.GASTOWN_CONTAINER_TOKEN = token; - log.info('token_refresh.succeeded', { durationMs: Date.now() - t0 }); + log.info('token_refresh.succeeded', { townId, durationMs: Date.now() - t0 }); return token; } catch (err) { log.warn('token_refresh.network_error', { + townId, error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - t0, }); @@ -124,6 +127,7 @@ export async function refreshTokenIfNearExpiry(thresholdMs = 30 * 60_000): Promi return; } log.info('token_refresh.boot_near_expiry', { + townId: process.env.GASTOWN_TOWN_ID ?? null, msUntilExpiry: msLeft, thresholdMs, }); diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 3d96e78bfe..88a99eecc3 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -3515,6 +3515,7 @@ export class TownDO extends DurableObject { ) .catch(err => console.warn(`${TOWN_LOG} slingConvoy: createConvoyBranch failed (non-fatal)`, { + townId: this.townId, error: err instanceof Error ? err.message : String(err), }) ); @@ -3997,7 +3998,7 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async alarm(): Promise { - return withLogTags({ source: 'Town.do' }, async () => { + return withLogTags({ source: 'Town.do', tags: { townId: this.townId } }, async () => { await this._alarm(); }); } diff --git a/services/gastown/src/dos/town/container-idle-stop.ts b/services/gastown/src/dos/town/container-idle-stop.ts index 03a0205443..f9edaa88ab 100644 --- a/services/gastown/src/dos/town/container-idle-stop.ts +++ b/services/gastown/src/dos/town/container-idle-stop.ts @@ -54,6 +54,7 @@ export async function stopContainerIfIdle(deps: IdleStopDeps): Promise { state = await stub.getState(); } catch (err) { logger.warn('stopContainerIfIdle: getState() failed', { + townId, error: err instanceof Error ? err.message : String(err), }); return; @@ -73,6 +74,7 @@ export async function stopContainerIfIdle(deps: IdleStopDeps): Promise { deps.writeEventFn({ event: 'container.idle_stop', townId, reason }); } catch (err) { logger.warn('stopContainerIfIdle: stop() failed', { + townId, error: err instanceof Error ? err.message : String(err), }); deps.writeEventFn({ diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 86b6732a7e..c4e6cf37e5 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -10,7 +10,7 @@ import { getTownDOStub } from './dos/Town.do'; import { TownConfigUpdateSchema } from './types'; import { resError } from './util/res.util'; import { writeEvent } from './util/analytics.util'; -import { logger } from './util/log.util'; +import { logger, withLogTags } from './util/log.util'; import { authMiddleware, agentOnlyMiddleware, @@ -173,6 +173,11 @@ export type GastownEnv = { const app = new Hono(); const LOCAL_DEV_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +function getTownIdFromPath(pathname: string): string | undefined { + const match = pathname.match(/(?:^|\/)towns\/([^/]+)/); + return match?.[1]; +} + async function cfAccessDebugMiddleware(c: Context, next: () => Promise) { const hostname = new URL(c.req.url).hostname; if (c.env.ENVIRONMENT === 'development' && LOCAL_DEV_HOSTNAMES.has(hostname)) { @@ -185,8 +190,10 @@ async function cfAccessDebugMiddleware(c: Context, next: () => Promi audience: c.env.CF_ACCESS_AUD, }); } catch (e) { - console.warn(`CF Access validation failed ${e instanceof Error ? e.message : 'unknown'}`, { - error: e, + const townId = getTownIdFromPath(new URL(c.req.url).pathname); + logger.warn(`CF Access validation failed ${e instanceof Error ? e.message : 'unknown'}`, { + ...(townId ? { townId } : {}), + error: e instanceof Error ? e.message : String(e), }); return c.json({ success: false, error: 'Unauthorized' }, 401); } @@ -218,6 +225,11 @@ app.use('/api/towns/:townId/*', async (c, next) => { if (townId) logger.setTags({ townId }); await next(); }); +app.use('/debug/towns/:townId/*', async (c, next) => { + const townId = c.req.param('townId'); + if (townId) logger.setTags({ townId }); + await next(); +}); app.use('/api/mayor/:townId/*', async (c, next) => { const townId = c.req.param('townId'); if (townId) logger.setTags({ townId }); @@ -263,6 +275,11 @@ app.use('/api/mayor/:townId/tools/rigs/:rigId/agents/:agentId/*', async (c, next if (agentId) logger.setTags({ agentId }); await next(); }); +app.use('/api/towns/:townId/container/agents/:agentId/*', async (c, next) => { + const agentId = c.req.param('agentId'); + if (agentId) logger.setTags({ agentId }); + await next(); +}); // ── CORS ──────────────────────────────────────────────────────────────── // Allow browser requests from the main Kilo app. In development, allow @@ -1357,7 +1374,10 @@ app.use( orgMemberships: c.get('kiloOrgMemberships') ?? [], }), onError: ({ error, path }: { error: Error; path?: string }) => { - console.error(`[gastown-trpc] error on ${path ?? 'unknown'}:`, error.message); + logger.error('[gastown-trpc] error', { + path: path ?? 'unknown', + error: error.message, + }); if (!(error instanceof TRPCError)) { Sentry.captureException(error); } @@ -1370,7 +1390,12 @@ app.use( app.notFound(c => c.json(resError('Not found'), 404)); app.onError((err, c) => { - console.error('Unhandled error', { error: err.message, stack: err.stack }); + const townId = getTownIdFromPath(new URL(c.req.url).pathname); + logger.error('Unhandled error', { + ...(townId ? { townId } : {}), + error: err.message, + stack: err.stack, + }); Sentry.captureException(err); return c.json(resError('Internal server error'), 500); }); @@ -1409,9 +1434,11 @@ export default withSentry( if (streamMatch) { const townId = streamMatch[1]; const agentId = streamMatch[2]; - console.log(`[gastown-worker] WS upgrade (stream): townId=${townId} agentId=${agentId}`); - const stub = getTownContainerStub(env, townId); - return stub.fetch(request); + return withLogTags({ source: 'gastown-worker', tags: { townId, agentId } }, async () => { + logger.info('WS upgrade stream'); + const stub = getTownContainerStub(env, townId); + return stub.fetch(request); + }); } // PTY terminal connection @@ -1420,20 +1447,22 @@ export default withSentry( const townId = ptyMatch[1]; const agentId = ptyMatch[2]; const ptyId = ptyMatch[3]; - console.log( - `[gastown-worker] WS upgrade (pty): townId=${townId} agentId=${agentId} ptyId=${ptyId}` - ); - const stub = getTownContainerStub(env, townId); - return stub.fetch(request); + return withLogTags({ source: 'gastown-worker', tags: { townId, agentId } }, async () => { + logger.info('WS upgrade pty', { ptyId }); + const stub = getTownContainerStub(env, townId); + return stub.fetch(request); + }); } // Town alarm status (real-time push) const statusMatch = url.pathname.match(WS_STATUS_PATTERN); if (statusMatch) { const townId = statusMatch[1]; - console.log(`[gastown-worker] WS upgrade (status): townId=${townId}`); - const stub = getTownDOStub(env, townId); - return stub.fetch(request); + return withLogTags({ source: 'gastown-worker', tags: { townId } }, async () => { + logger.info('WS upgrade status'); + const stub = getTownDOStub(env, townId); + return stub.fetch(request); + }); } } diff --git a/services/gastown/src/trpc/init.ts b/services/gastown/src/trpc/init.ts index bea8bb9b6d..ce6c640fd9 100644 --- a/services/gastown/src/trpc/init.ts +++ b/services/gastown/src/trpc/init.ts @@ -1,5 +1,7 @@ import { initTRPC, TRPCError } from '@trpc/server'; +import { z } from 'zod'; import { writeEvent } from '../util/analytics.util'; +import { logger } from '../util/log.util'; import type { JwtOrgMembership } from '../middleware/auth.middleware'; @@ -17,12 +19,23 @@ const t = initTRPC.context().create(); export const router = t.router; +const RawInputWithTownId = z.object({ townId: z.string().uuid() }).passthrough(); + +function getTownIdFromInput(input: unknown): string | undefined { + const parsed = RawInputWithTownId.safeParse(input); + return parsed.success ? parsed.data.townId : undefined; +} + /** * Analytics middleware — wraps every tRPC procedure to emit an analytics * event with timing and error capture. Runs before auth so even rejected * requests are tracked. */ -const analyticsProcedure = t.procedure.use(async ({ ctx, path, type, next }) => { +const analyticsProcedure = t.procedure.use(async ({ ctx, getRawInput, path, type, next }) => { + const rawInput = await getRawInput(); + const townId = getTownIdFromInput(rawInput); + if (townId) logger.setTags({ townId }); + const start = performance.now(); let error: string | undefined; try { diff --git a/services/gastown/src/trpc/router.ts b/services/gastown/src/trpc/router.ts index 5ccd605417..562c8b3247 100644 --- a/services/gastown/src/trpc/router.ts +++ b/services/gastown/src/trpc/router.ts @@ -20,6 +20,7 @@ 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'; +import { logger } from '../util/log.util'; import { RpcTownOutput, RpcRigOutput, @@ -64,7 +65,7 @@ async function refreshGitCredentials( const result = await env.GIT_TOKEN_SERVICE.getTokenForRepo({ githubRepo, userId, orgId }); if (!result.success) { - console.warn(`[gastown-trpc] git credential refresh failed: ${result.reason}`); + logger.warn('[gastown-trpc] git credential refresh failed', { townId, reason: result.reason }); return; } @@ -113,7 +114,10 @@ async function refreshFirstGithubRigCredentials(params: { durationMs: Date.now() - start, error: err instanceof Error ? err.message : String(err), }); - console.warn('[gastown-trpc] ensureMayor: git credential refresh failed', err); + logger.warn('[gastown-trpc] ensureMayor: git credential refresh failed', { + townId: params.townId, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -348,9 +352,15 @@ async function verifyRigOwnership(env: Env, ctx: TRPCContext, rigId: string, tow throw new TRPCError({ code: 'NOT_FOUND', message: 'Rig not found' }); } -async function mintKilocodeToken(env: Env, user: { id: string; api_token_pepper: string | null }) { +async function mintKilocodeToken( + env: Env, + user: { id: string; api_token_pepper: string | null }, + townId?: string +) { if (!env.NEXTAUTH_SECRET) { - console.error('[mintKilocodeToken] NEXTAUTH_SECRET not configured'); + logger.error('[mintKilocodeToken] NEXTAUTH_SECRET not configured', { + ...(townId ? { townId } : {}), + }); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error', @@ -358,7 +368,9 @@ async function mintKilocodeToken(env: Env, user: { id: string; api_token_pepper: } const secret = await resolveSecret(env.NEXTAUTH_SECRET); if (!secret) { - console.error('[mintKilocodeToken] failed to resolve NEXTAUTH_SECRET from Secrets Store'); + logger.error('[mintKilocodeToken] failed to resolve NEXTAUTH_SECRET from Secrets Store', { + ...(townId ? { townId } : {}), + }); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error', @@ -381,7 +393,7 @@ export const gastownRouter = router({ const town = await userStub.createTown({ name: input.name, owner_user_id: user.id }); // Store kilocode token so agents can auth with the Kilo LLM gateway - const kilocodeToken = await mintKilocodeToken(ctx.env, user); + const kilocodeToken = await mintKilocodeToken(ctx.env, user, town.id); const townStub = getTownDOStub(ctx.env, town.id); await townStub.setTownId(town.id); await townStub.updateTownConfig({ @@ -523,7 +535,7 @@ export const gastownRouter = router({ // a non-owner member adds a rig, keep the existing town token. let kilocodeToken: string | undefined; if (credentialUserId === user.id) { - kilocodeToken = await mintKilocodeToken(ctx.env, user); + kilocodeToken = await mintKilocodeToken(ctx.env, user, input.townId); await townStub.updateTownConfig({ kilocode_token: kilocodeToken }); } @@ -537,7 +549,10 @@ export const gastownRouter = router({ townConfig.organization_id ); } catch (err) { - console.warn('[gastown-trpc] createRig: git credential refresh failed', err); + logger.warn('[gastown-trpc] createRig: git credential refresh failed', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } const rig = await ownerStub.createRig({ @@ -567,10 +582,11 @@ export const gastownRouter = router({ defaultBranch: input.defaultBranch, }); } catch (err) { - console.error( - `[gastown-trpc] createRig: Town DO configure FAILED for rig ${rig.id}, rolling back:`, - err - ); + logger.error('[gastown-trpc] createRig: Town DO configure FAILED, rolling back', { + townId: input.townId, + rigId: rig.id, + error: err instanceof Error ? err.message : String(err), + }); try { await ownerStub.deleteRig(rig.id); } catch { @@ -878,7 +894,10 @@ export const gastownRouter = router({ townConfig.organization_id ); } catch (err) { - console.warn('[gastown-trpc] sling: git credential refresh failed', err); + logger.warn('[gastown-trpc] sling: git credential refresh failed', { + townId: rig.town_id, + error: err instanceof Error ? err.message : String(err), + }); } const townStub = getTownDOStub(ctx.env, rig.town_id); @@ -927,9 +946,12 @@ export const gastownRouter = router({ // Mayor notification can start a container — use waitUntil so the Worker // stays alive until the RPC completes without blocking the HTTP response. ctx.executionCtx.waitUntil( - townStub - .notifyMayorOfNewBead(bead.bead_id, rig.id, input.title, input.body) - .catch(err => console.warn('[gastown-trpc] createBead: mayor notification failed', err)) + townStub.notifyMayorOfNewBead(bead.bead_id, rig.id, input.title, input.body).catch(err => + logger.warn('[gastown-trpc] createBead: mayor notification failed', { + townId: rig.town_id, + error: err instanceof Error ? err.message : String(err), + }) + ) ); return bead; @@ -1255,7 +1277,10 @@ export const gastownRouter = router({ try { await townStub.syncConfigToContainer(); } catch (err) { - console.warn('[gastown-trpc] updateTownConfig: syncConfigToContainer failed:', err); + logger.warn('[gastown-trpc] updateTownConfig: syncConfigToContainer failed', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } // Rewrite the mayor's AGENTS.md when custom instructions change so the @@ -1266,7 +1291,10 @@ export const gastownRouter = router({ try { await townStub.updateMayorSystemPrompt(); } catch (err) { - console.warn('[gastown-trpc] updateTownConfig: updateMayorSystemPrompt failed:', err); + logger.warn('[gastown-trpc] updateTownConfig: updateMayorSystemPrompt failed', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -1287,7 +1315,10 @@ export const gastownRouter = router({ try { await townStub.updateMayorModel(newMayorModel, result.small_model ?? undefined); } catch (err) { - console.warn('[gastown-trpc] updateTownConfig: updateMayorModel failed:', err); + logger.warn('[gastown-trpc] updateTownConfig: updateMayorModel failed', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -1344,7 +1375,7 @@ export const gastownRouter = router({ tokenUser = userFromCtx(ctx); } } - const newKilocodeToken = await mintKilocodeToken(ctx.env, tokenUser); + const newKilocodeToken = await mintKilocodeToken(ctx.env, tokenUser, input.townId); await townStub.updateTownConfig({ kilocode_token: newKilocodeToken }); await townStub.syncConfigToContainer(); }), @@ -1557,7 +1588,7 @@ export const gastownRouter = router({ // Mint kilocode token so the mayor can start without waiting for rig creation const user = userFromCtx(ctx); - const kilocodeToken = await mintKilocodeToken(ctx.env, user); + const kilocodeToken = await mintKilocodeToken(ctx.env, user, town.id); const townStub = getTownDOStub(ctx.env, town.id); await townStub.setTownId(town.id); @@ -1587,10 +1618,10 @@ export const gastownRouter = router({ const townStub = getTownDOStub(ctx.env, input.townId); await townStub.destroy(); } catch (err) { - console.error( - `[gastown-trpc] deleteOrgTown: failed to destroy Town DO for ${input.townId}:`, - err - ); + logger.error('[gastown-trpc] deleteOrgTown: failed to destroy Town DO', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } await stub.deleteTown(input.townId); @@ -1638,7 +1669,7 @@ export const gastownRouter = router({ const credentialUserId = townConfig.owner_user_id ?? ctx.userId; let kilocodeToken: string | undefined; if (credentialUserId === ctx.userId) { - kilocodeToken = await mintKilocodeToken(ctx.env, userFromCtx(ctx)); + kilocodeToken = await mintKilocodeToken(ctx.env, userFromCtx(ctx), input.townId); await townStub.updateTownConfig({ kilocode_token: kilocodeToken }); } @@ -1652,7 +1683,10 @@ export const gastownRouter = router({ townConfig.organization_id ); } catch (err) { - console.warn('[gastown-trpc] createOrgRig: git credential refresh failed', err); + logger.warn('[gastown-trpc] createOrgRig: git credential refresh failed', { + townId: input.townId, + error: err instanceof Error ? err.message : String(err), + }); } const rig = await orgStub.createRig({ @@ -1680,10 +1714,11 @@ export const gastownRouter = router({ defaultBranch: input.defaultBranch, }); } catch (err) { - console.error( - `[gastown-trpc] createOrgRig: Town DO configure FAILED for rig ${rig.id}, rolling back:`, - err - ); + logger.error('[gastown-trpc] createOrgRig: Town DO configure FAILED, rolling back', { + townId: input.townId, + rigId: rig.id, + error: err instanceof Error ? err.message : String(err), + }); try { await orgStub.deleteRig(rig.id); } catch {