diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acc..547995a09 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1504,9 +1504,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // (which would create split-brain — two agents writing the port file, // tokens diverging between them, mystery PTY upgrade failures). // - // Crash-loop guard: 3 respawn attempts inside 60s → stop trying and emit - // a one-line error. Manual `forceRestart` from the sidebar clears the - // history (the user is the explicit signal to retry). + // Crash-loop guard: 3 respawn attempts inside 3 ticks → stop trying and + // emit a one-line error. Manual `forceRestart` from the sidebar clears + // the history (the user is the explicit signal to retry). // // Only active when ownsTerminalAgent === true. Embedders that pre-launch // their own PTY server (gbrowser phoenix overlay) must not be auto-respawned @@ -1517,8 +1517,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000', 10, ); - const RESPAWN_GUARD_WINDOW_MS = 60_000; const RESPAWN_GUARD_MAX = 3; + // The guard must span at least RESPAWN_GUARD_MAX ticks or it can never + // trip: respawn attempts land at most once per tick, so a 60s window + // over a 60s tick sees a single attempt forever (see #2151). + const RESPAWN_GUARD_WINDOW_MS = Math.max(60_000, AGENT_WATCHDOG_TICK_MS * RESPAWN_GUARD_MAX); let agentRespawnGuardTripped = false; if (ownsTerminalAgent) { diff --git a/browse/test/terminal-agent-watchdog.test.ts b/browse/test/terminal-agent-watchdog.test.ts index f012dc406..c6cdfd6aa 100644 --- a/browse/test/terminal-agent-watchdog.test.ts +++ b/browse/test/terminal-agent-watchdog.test.ts @@ -50,7 +50,12 @@ describe('terminal-agent watchdog (v1.44+)', () => { test('4. crash-loop guard with rolling window', () => { const src = fs.readFileSync(SERVER_TS, 'utf-8'); const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth'); - expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000'); + // The window must span at least RESPAWN_GUARD_MAX ticks. Respawn + // attempts land at most once per tick, so a fixed 60s window over a + // 60s tick would see a single attempt forever and never trip (#2151). + expect(block).toContain( + 'RESPAWN_GUARD_WINDOW_MS = Math.max(60_000, AGENT_WATCHDOG_TICK_MS * RESPAWN_GUARD_MAX)', + ); expect(block).toContain('RESPAWN_GUARD_MAX = 3'); expect(block).toContain('respawnHistory'); expect(block).toContain('agentRespawnGuardTripped');