fix: terminal-agent respawn guard window spans guard-max ticks

The crash-loop guard (3 respawns inside RESPAWN_GUARD_WINDOW_MS -> stop
and require manual restart) could never trip: respawn attempts land at
most once per watchdog tick, and the window (60s) equaled the tick
interval (60s), so the rolling history never held more than one entry.
A crash-looping agent respawned once a minute for the daemon lifetime
(#2151). Size the window as max(60s, tick * guard-max) so three
consecutive failed ticks trip the guard at any tick setting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Southgate 2026-07-02 21:23:53 +01:00
parent f48623ecac
commit b4c22b04dc
2 changed files with 13 additions and 5 deletions

View File

@ -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) {

View File

@ -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');