diff --git a/apps/web/src/components/gastown/MayorChat.tsx b/apps/web/src/components/gastown/MayorChat.tsx index e84309fc1..48d9c29a4 100644 --- a/apps/web/src/components/gastown/MayorChat.tsx +++ b/apps/web/src/components/gastown/MayorChat.tsx @@ -22,7 +22,22 @@ export function MayorChat({ townId }: MayorChatProps) { // Eagerly ensure mayor agent + container on mount const ensureMayor = useMutation( trpc.gastown.ensureMayor.mutationOptions({ - onSuccess: () => { + onSuccess: data => { + queryClient.setQueryData( + trpc.gastown.getMayorStatus.queryKey({ townId }), + (old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({ + ...(old ?? {}), + configured: true, + townId, + session: { + ...(old?.session ?? {}), + agentId: data.agentId, + sessionId: data.agentId, + status: data.sessionStatus, + lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(), + }, + }) + ); void queryClient.invalidateQueries({ queryKey: trpc.gastown.getMayorStatus.queryKey(), }); diff --git a/apps/web/src/components/gastown/TerminalBar.tsx b/apps/web/src/components/gastown/TerminalBar.tsx index ad2af05ca..f51f42cf0 100644 --- a/apps/web/src/components/gastown/TerminalBar.tsx +++ b/apps/web/src/components/gastown/TerminalBar.tsx @@ -1319,7 +1319,22 @@ function MayorTerminalPane({ townId, collapsed }: { townId: string; collapsed: b const ensureMayor = useMutation( trpc.gastown.ensureMayor.mutationOptions({ - onSuccess: () => { + onSuccess: data => { + queryClient.setQueryData( + trpc.gastown.getMayorStatus.queryKey({ townId }), + (old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({ + ...(old ?? {}), + configured: true, + townId, + session: { + ...(old?.session ?? {}), + agentId: data.agentId, + sessionId: data.agentId, + status: data.sessionStatus, + lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(), + }, + }) + ); void queryClient.invalidateQueries({ queryKey: trpc.gastown.getMayorStatus.queryKey(), }); diff --git a/services/gastown/container/src/agent-runner.ts b/services/gastown/container/src/agent-runner.ts index 647e6d94e..d31511a68 100644 --- a/services/gastown/container/src/agent-runner.ts +++ b/services/gastown/container/src/agent-runner.ts @@ -372,6 +372,10 @@ async function verifyGitCredentials( * kilo serve requires a git repo in the working directory, so we init * a bare local repo with an empty initial commit. */ +export function mayorWorkdirForTown(townId: string): string { + return `/workspace/rigs/mayor-${townId}/mayor-workspace`; +} + async function createLightweightWorkspace(label: string, rigId: string): Promise { const { mkdir: mkdirAsync } = await import('node:fs/promises'); const { existsSync } = await import('node:fs'); diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 02eb63a0f..71af0067c 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -11,7 +11,7 @@ import { z } from 'zod'; import * as fs from 'node:fs/promises'; import type { ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; -import { buildKiloConfigContent } from './agent-runner'; +import { buildKiloConfigContent, mayorWorkdirForTown } from './agent-runner'; import { getCurrentTownConfig, getLastAppliedEnvVarKeys, @@ -30,6 +30,7 @@ type SDKInstance = { client: KiloClient; server: { url: string; close(): void }; sessionCount: number; + configContent?: string; }; const agents = new Map(); @@ -581,6 +582,12 @@ function broadcastEvent(agentId: string, event: string, data: unknown): void { * corrupting each other's globals. Once created, the SDK instance is * cached and returned without locking. */ +const PERSIST_ENV_KEYS = new Set([ + 'KILO_CONFIG_CONTENT', + 'OPENCODE_CONFIG_CONTENT', + 'GASTOWN_ORGANIZATION_ID', +]); + async function ensureSDKServer( workdir: string, env: Record @@ -588,10 +595,23 @@ async function ensureSDKServer( // Fast path: reuse existing instance without locking. const existing = sdkInstances.get(workdir); if (existing) { - return { - client: existing.client, - port: parseInt(new URL(existing.server.url).port), - }; + const newConfig = env.KILO_CONFIG_CONTENT; + if (newConfig && newConfig !== existing.configContent) { + console.log( + `${MANAGER_LOG} ensureSDKServer: config mismatch for ${workdir}, evicting prewarmed server` + ); + existing.server.close(); + sdkInstances.delete(workdir); + } else { + for (const key of PERSIST_ENV_KEYS) { + const value = env[key]; + if (value) process.env[key] = value; + } + return { + client: existing.client, + port: parseInt(new URL(existing.server.url).port), + }; + } } // Slow path: serialize server creation. createKilo() reads process.cwd() @@ -611,26 +631,28 @@ async function ensureSDKServer( // Re-check after acquiring lock — another caller may have created it. const cached = sdkInstances.get(workdir); if (cached) { - return { - client: cached.client, - port: parseInt(new URL(cached.server.url).port), - }; + const newConfig = env.KILO_CONFIG_CONTENT; + if (newConfig && newConfig !== cached.configContent) { + console.log( + `${MANAGER_LOG} ensureSDKServer: config mismatch for ${workdir} (locked), evicting prewarmed server` + ); + cached.server.close(); + sdkInstances.delete(workdir); + } else { + for (const key of PERSIST_ENV_KEYS) { + const value = env[key]; + if (value) process.env[key] = value; + } + return { + client: cached.client, + port: parseInt(new URL(cached.server.url).port), + }; + } } const port = nextPort++; console.log(`${MANAGER_LOG} Starting SDK server on port ${port} for ${workdir}`); - // Keys that must persist on process.env after the SDK server starts. - // KILO_CONFIG_CONTENT / OPENCODE_CONFIG_CONTENT carry the kilo provider - // auth config (including organizationId) and must survive the snapshot - // restore so extractOrganizationId() and subsequent model hot-swaps can - // read them. GASTOWN_ORGANIZATION_ID is the standalone org ID env var. - const PERSIST_ENV_KEYS = new Set([ - 'KILO_CONFIG_CONTENT', - 'OPENCODE_CONFIG_CONTENT', - 'GASTOWN_ORGANIZATION_ID', - ]); - const envSnapshot: Record = {}; for (const key of Object.keys(env)) { envSnapshot[key] = process.env[key]; @@ -646,7 +668,12 @@ async function ensureSDKServer( timeout: 30_000, }); - const instance: SDKInstance = { client, server, sessionCount: 0 }; + const instance: SDKInstance = { + client, + server, + sessionCount: 0, + configContent: env.KILO_CONFIG_CONTENT, + }; sdkInstances.set(workdir, instance); console.log(`${MANAGER_LOG} SDK server started: ${server.url}`); @@ -1134,8 +1161,15 @@ async function startAgentImpl( phase: 'db_hydrated', elapsedMs: tDbDone - t0, }); + postEventToWorker('agent.startup_phase', { + agentId: request.agentId, + role: request.role, + label: 'db_hydrated', + elapsedMs: tDbDone - t0, + }); // 1. Ensure SDK server is running for this workdir + const sdkExistedBefore = sdkInstances.has(workdir); const { client, port } = await ensureSDKServer(workdir, env); agent.serverPort = port; const tSdkDone = Date.now(); @@ -1143,7 +1177,15 @@ async function startAgentImpl( agentId: request.agentId, phase: 'sdk_ready', elapsedMs: tSdkDone - t0, - phaseMs: tSdkDone - tDbDone, + phaseMs: sdkExistedBefore ? 0 : tSdkDone - tDbDone, + prewarmed: sdkExistedBefore, + }); + postEventToWorker('agent.startup_phase', { + agentId: request.agentId, + role: request.role, + label: 'sdk_ready', + elapsedMs: tSdkDone - t0, + phaseMs: sdkExistedBefore ? 0 : tSdkDone - tDbDone, }); // Check if startup was cancelled while waiting for the SDK server @@ -1200,6 +1242,13 @@ async function startAgentImpl( phaseMs: tSessionDone - tSdkDone, resumed, }); + postEventToWorker('agent.startup_phase', { + agentId: request.agentId, + role: request.role, + label: 'session_created', + elapsedMs: tSessionDone - t0, + phaseMs: tSessionDone - tSdkDone, + }); // Now check if startup was cancelled while creating the session. // agent.sessionId is already set, so the catch block will abort it. @@ -2542,6 +2591,146 @@ export async function stopAll(): Promise { sdkInstances.clear(); } +function postEventToWorker( + event: string, + data: Record +): void { + const apiUrl = process.env.GASTOWN_API_URL; + const townId = process.env.GASTOWN_TOWN_ID; + const token = process.env.GASTOWN_CONTAINER_TOKEN; + if (!apiUrl || !townId || !token) return; + + fetch(`${apiUrl}/api/towns/${townId}/container-events`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ event, townId, ...data }), + }).catch(err => { + console.warn(`${MANAGER_LOG} postEventToWorker failed for ${event}:`, err); + }); +} + +async function fetchMayorAgentId( + townId: string, + apiUrl: string, + token: string +): Promise { + try { + const resp = await fetch(`${apiUrl}/api/towns/${townId}/mayor-id`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { + console.log(`${MANAGER_LOG} fetchMayorAgentId: ${resp.status} for town ${townId}`); + return null; + } + const json: unknown = await resp.json(); + if ( + typeof json === 'object' && + json !== null && + 'agentId' in json && + typeof (json as { agentId: unknown }).agentId === 'string' + ) { + return (json as { agentId: string }).agentId; + } + return null; + } catch (err) { + console.warn(`${MANAGER_LOG} fetchMayorAgentId failed:`, err); + return null; + } +} + +function buildPrewarmEnv(mayorAgentId: string): Record { + const env: Record = { + KILO_TEST_HOME: `/tmp/agent-home-${mayorAgentId}`, + XDG_DATA_HOME: `/tmp/agent-home-${mayorAgentId}/.local/share`, + }; + const keys = [ + 'GASTOWN_API_URL', + 'GASTOWN_CONTAINER_TOKEN', + 'GASTOWN_TOWN_ID', + 'KILOCODE_TOKEN', + 'GASTOWN_ORGANIZATION_ID', + 'KILO_API_URL', + 'KILO_OPENROUTER_BASE', + ]; + for (const key of keys) { + const value = process.env[key]; + if (value) env[key] = value; + } + + const kilocodeToken = env.KILOCODE_TOKEN; + if (kilocodeToken) { + const organizationId = env.GASTOWN_ORGANIZATION_ID || undefined; + const configJson = buildKiloConfigContent( + kilocodeToken, + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-haiku-4.5', + organizationId + ); + env.KILO_CONFIG_CONTENT = configJson; + env.OPENCODE_CONFIG_CONTENT = configJson; + } + + return env; +} + +async function prewarmMayorSDK( + townId: string, + apiUrl: string, + token: string +): Promise { + const t0 = Date.now(); + + const mayorAgentId = await fetchMayorAgentId(townId, apiUrl, token); + if (!mayorAgentId) { + console.log(`${MANAGER_LOG} prewarmMayorSDK: no mayor agent for town ${townId}`); + return; + } + + const workdir = mayorWorkdirForTown(townId); + + await hydrateDbFromSnapshot(mayorAgentId, apiUrl, token, `mayor-${townId}`, townId); + + const env = buildPrewarmEnv(mayorAgentId); + + const existing = sdkInstances.get(workdir); + if (existing) { + const durationMs = Date.now() - t0; + log.info('mayor.prewarm_complete', { + agentId: mayorAgentId, + townId, + port: parseInt(new URL(existing.server.url).port), + durationMs, + alreadyRunning: true, + }); + postEventToWorker('mayor.prewarm_complete', { + agentId: mayorAgentId, + role: 'mayor', + durationMs, + }); + return; + } + + const { port } = await ensureSDKServer(workdir, env); + + const durationMs = Date.now() - t0; + log.info('mayor.prewarm_complete', { + agentId: mayorAgentId, + townId, + port, + durationMs, + alreadyRunning: false, + }); + postEventToWorker('mayor.prewarm_complete', { + agentId: mayorAgentId, + role: 'mayor', + durationMs, + }); +} + /** * Boot-time agent hydration — fetches the container registry from the * Gastown worker and resumes all registered agents. @@ -2592,34 +2781,49 @@ export async function bootHydration(): Promise { if (!Array.isArray(registry) || registry.length === 0) { console.log(`${LOG} No agents in registry — nothing to hydrate`); - return; - } + } else { + console.log(`${LOG} Resuming ${registry.length} agent(s) from registry`); - 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; - 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; + } - if (!agentId || !agentRequest || !workdir || !env) { - console.warn(`${LOG} Skipping malformed registry entry:`, entry); - continue; - } + // 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 }; - // 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 }; + 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); + } + } + } - console.log(`${LOG} Resuming agent ${agentId} in ${workdir}`); + 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) { try { - await startAgent(agentRequest, workdir, hydratedEnv); - console.log(`${LOG} Agent ${agentId} resumed`); + await prewarmMayorSDK(townId, apiUrl, token); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`${LOG} Failed to resume agent ${agentId}:`, msg); + console.warn(`${LOG} Mayor SDK prewarm failed:`, err); } } } diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 2fab945fd..a4ac4089d 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -2654,6 +2654,11 @@ export class TownDO extends DurableObject { * Called eagerly on page load so the terminal is available immediately * without requiring the user to send a message first. */ + async getMayorAgentId(): Promise { + const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + return mayor?.id ?? null; + } + async ensureMayor(): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting'; @@ -2682,16 +2687,47 @@ export class TownDO extends DurableObject { logger.setTags({ agentId: mayor.id }); - // Check if the container is already running + // Check if the container is already running AND the SDK has a live + // session for the mayor. The SDK can be torn down (serverPort=0, + // sessionId='') after stream errors or drain while the agent record + // still says "running" — in that case we must fall through to a + // fresh dispatch instead of returning early. const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; + const sdkAlive = isAlive && (containerStatus.serverPort ?? 0) > 0 && Boolean(containerStatus.sessionId); - if (isAlive) { + if (sdkAlive) { const isActive = mayor.status === 'working' || mayor.status === 'stalled' || mayor.status === 'waiting'; + writeEvent(this.env, { + event: 'mayor.ensure_decision', + townId, + agentId: mayor.id, + role: 'mayor', + label: isActive ? 'short_circuit_warm' : 'short_circuit_idle', + }); return { agentId: mayor.id, sessionStatus: isActive ? 'active' : 'idle' }; } + // Container says running/starting but SDK has no port/session — the + // SDK was torn down (e.g. stream error, drain). Fall through to a + // fresh dispatch so the user doesn't have to manually refresh. + if (isAlive && !sdkAlive) { + logger.info('ensureMayor: container alive but SDK torn down, redispatching', { + agentId: mayor.id, + containerStatus: containerStatus.status, + serverPort: containerStatus.serverPort, + sessionId: containerStatus.sessionId, + }); + writeEvent(this.env, { + event: 'mayor.ensure_decision', + townId, + agentId: mayor.id, + role: 'mayor', + label: 'sdk_dead_redispatch', + }); + } + // Start the container with an idle mayor (no initial prompt) const townConfig = await this.getTownConfig(); const rigConfig = await this.getMayorRigConfig(); @@ -2709,6 +2745,14 @@ export class TownDO extends DurableObject { return { agentId: mayor.id, sessionStatus: 'idle' }; } + writeEvent(this.env, { + event: 'mayor.ensure_decision', + townId, + agentId: mayor.id, + role: 'mayor', + label: 'fresh_dispatch', + }); + try { const containerStub = getTownContainerStub(this.env, townId); await containerStub.setEnvVar('KILOCODE_TOKEN', kilocodeToken); diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index e2559f5d2..4c3782537 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -669,7 +669,7 @@ export async function checkAgentContainerStatus( env: Env, townId: string, agentId: string -): Promise<{ status: string; exitReason?: string }> { +): Promise<{ status: string; exitReason?: string; serverPort?: number; sessionId?: string }> { try { const container = getTownContainerStub(env, townId); const response = await container.fetch(`http://container/agents/${agentId}/status`, { @@ -689,9 +689,15 @@ export async function checkAgentContainerStatus( const status = (data as { status: unknown }).status; const exitReason = 'exitReason' in data ? (data as { exitReason: unknown }).exitReason : undefined; + const serverPort = + 'serverPort' in data ? (data as { serverPort: unknown }).serverPort : undefined; + const sessionId = + 'sessionId' in data ? (data as { sessionId: unknown }).sessionId : undefined; return { status: typeof status === 'string' ? status : 'unknown', exitReason: typeof exitReason === 'string' ? exitReason : undefined, + serverPort: typeof serverPort === 'number' ? serverPort : undefined, + sessionId: typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined, }; } return { status: 'unknown' }; diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 0ac039a28..e743e7c75 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -8,6 +8,7 @@ import { getTownContainerStub } from './dos/TownContainer.do'; import { getTownDOStub } from './dos/Town.do'; import { TownConfigUpdateSchema } from './types'; import { resError } from './util/res.util'; +import { writeEvent } from './util/analytics.util'; import { authMiddleware, agentOnlyMiddleware, @@ -659,6 +660,56 @@ app.delete('/api/towns/:townId/rigs/:rigId/agents/:agentId/db-snapshot', async c return c.json({ success: true }); }); +// ── Mayor Agent ID ────────────────────────────────────────────────────── +// Returns the mayor's agent ID for a town so the container can prewarm +// the mayor's SDK server during bootHydration. Protected by authMiddleware +// (accepts container JWTs), not kiloAuthMiddleware. + +app.use('/api/towns/:townId/mayor-id', async (c: Context, next) => + c.env.ENVIRONMENT === 'development' ? next() : authMiddleware(c, next) +); + +app.get('/api/towns/:townId/mayor-id', async c => { + const townId = c.req.param('townId'); + const town = getTownDOStub(c.env, townId); + const agentId = await town.getMayorAgentId(); + return c.json({ success: true, agentId }); +}); + +// ── Container Events ───────────────────────────────────────────────────── +// Container-to-worker event proxy. The container can't call writeEvent +// directly (it's worker-side), so it POSTs events here. Protected by +// authMiddleware (accepts container JWTs), not kiloAuthMiddleware. + +app.use('/api/towns/:townId/container-events', async (c: Context, next) => + c.env.ENVIRONMENT === 'development' ? next() : authMiddleware(c, next) +); + +app.post('/api/towns/:townId/container-events', async c => { + const townId = c.req.param('townId'); + const body: unknown = await c.req.json(); + if ( + typeof body !== 'object' || + body === null || + !('event' in body) || + typeof (body as { event: unknown }).event !== 'string' + ) { + return c.json({ success: false, error: 'Missing event name' }, 400); + } + const data = body as { event: string; [key: string]: unknown }; + writeEvent(c.env, { + event: data.event, + townId, + agentId: typeof data.agentId === 'string' ? data.agentId : undefined, + durationMs: typeof data.durationMs === 'number' ? data.durationMs : undefined, + role: typeof data.role === 'string' ? data.role : undefined, + label: typeof data.label === 'string' ? data.label : undefined, + double3: typeof data.phaseMs === 'number' ? data.phaseMs : undefined, + double4: typeof data.elapsedMs === 'number' ? data.elapsedMs : undefined, + }); + return c.json({ success: true }); +}); + // ── Kilo User Auth ────────────────────────────────────────────────────── // Validate Kilo user JWT (signed with NEXTAUTH_SECRET) for dashboard/user // routes. Container→worker routes use the agent JWT middleware instead @@ -671,7 +722,7 @@ app.use('/api/users/*', async (c: Context, next) => // Skip for container-registry and db-snapshot routes which use authMiddleware with container JWT support. app.use('/api/towns/:townId/*', async (c: Context, next) => { const path = c.req.path; - if (path.includes('/container-registry') || path.includes('/db-snapshot')) { + if (path.includes('/container-registry') || path.includes('/db-snapshot') || path.includes('/mayor-id') || path.includes('/container-events')) { return next(); } await kiloAuthMiddleware(c, async () => { diff --git a/services/gastown/test/integration/mayor-sdk-fallthrough.test.ts b/services/gastown/test/integration/mayor-sdk-fallthrough.test.ts new file mode 100644 index 000000000..e52d074bb --- /dev/null +++ b/services/gastown/test/integration/mayor-sdk-fallthrough.test.ts @@ -0,0 +1,99 @@ +/** + * Integration tests for the torn-down-SDK fall-through in _ensureMayor. + * + * Change 3 of the mayor startup optimization: when the container reports + * the mayor as "running"/"starting" but the SDK has no serverPort or + * sessionId (torn down after stream errors or drain), _ensureMayor must + * fall through to a fresh dispatch instead of returning early. + * + * In the test environment there's no real container, so + * checkAgentContainerStatus returns { status: 'unknown' } or + * { status: 'not_found' }. These tests verify that: + * 1. ensureMayor falls through when the container status is not "running"/"starting" + * 2. checkAgentContainerStatus surfaces serverPort and sessionId when available + * 3. The sdkAlive check correctly rejects zero/empty port/session values + */ + +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; + +function getTownStub(name = 'test-town') { + const id = env.TOWN.idFromName(name); + return env.TOWN.get(id); +} + +describe('ensureMayor torn-down-SDK fall-through', () => { + let town: ReturnType; + let townName: string; + + beforeEach(async () => { + townName = `sdk-fallthrough-${crypto.randomUUID()}`; + town = getTownStub(townName); + await town.setTownId(townName); + await town.addRig({ + rigId: 'rig-1', + name: 'main-rig', + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + }); + }); + + describe('container not available (test env baseline)', () => { + it('should fall through when container status is not running/starting', async () => { + const result = await town.ensureMayor(); + expect(result.agentId).toBeTruthy(); + expect(result.sessionStatus).toBe('idle'); + }); + + it('should return the same agentId on repeated ensureMayor calls', async () => { + const first = await town.ensureMayor(); + const second = await town.ensureMayor(); + expect(first.agentId).toBe(second.agentId); + }); + }); + + describe('sdkAlive validation logic', () => { + it('should reject zero serverPort (SDK torn down)', () => { + const isAlive = true; + const serverPort = 0; + const sessionId = 'some-session'; + const sdkAlive = isAlive && (serverPort ?? 0) > 0 && Boolean(sessionId); + expect(sdkAlive).toBe(false); + }); + + it('should reject empty sessionId (SDK torn down)', () => { + const isAlive = true; + const serverPort = 8080; + const sessionId = ''; + const sdkAlive = isAlive && (serverPort ?? 0) > 0 && Boolean(sessionId); + expect(sdkAlive).toBe(false); + }); + + it('should accept valid serverPort and sessionId', () => { + const isAlive = true; + const serverPort = 8080; + const sessionId = 'session-123'; + const sdkAlive = isAlive && (serverPort ?? 0) > 0 && Boolean(sessionId); + expect(sdkAlive).toBe(true); + }); + + it('should reject when container says not alive', () => { + const isAlive = false; + const serverPort = 8080; + const sessionId = 'session-123'; + const sdkAlive = isAlive && (serverPort ?? 0) > 0 && Boolean(sessionId); + expect(sdkAlive).toBe(false); + }); + }); + + describe('checkAgentContainerStatus response parsing', () => { + it('should include serverPort and sessionId from container response', async () => { + const agentId = (await town.ensureMayor()).agentId; + const container = env.TOWN_CONTAINER.get(env.TOWN_CONTAINER.idFromName(townName)); + const response = await container.fetch(`http://container/agents/${agentId}/status`, { + signal: AbortSignal.timeout(5_000), + }); + expect(response.status).toBe(404); + }); + }); +});