fix(orchestrator): prevent runaway session spawning - #441
Conversation
Centaur ReviewFound 6 issue(s) (2 critical) (3 warning).
|
Centaur ReviewFound 5 issue(s) (2 warning).
|
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 6 issue(s) (2 warning).
frontend/src/styles/desktop.css
Solid incident fix — the ID namespace mismatch root cause, grace period, rate limiter, and kill switch are well-designed and thoroughly tested. Minor findings around mobile CSS scoping and defensive clarity in the rate limiter.
- 🔵 style (L711): Spawn button color styles (
.cc-spawn-enabled/.cc-spawn-disabled) are only defined inside the@media (min-width: 768px)desktop media query. On mobile viewports, the spawn toggle button will render unstyled (inheriting the base.cc-section-action-btncolor), so the enabled/disabled visual distinction is lost. Since Mitzo is described as mobile-first, consider adding these color rules toglobal.cssor to a non-media-scoped block.[fixable]
server/task-orchestrator.ts
Solid incident fix — the ID namespace mismatch root cause, grace period, rate limiter, and kill switch are well-designed and thoroughly tested. Minor findings around mobile CSS scoping and defensive clarity in the rate limiter.
- 🟡 unsafe_assumptions (L431):
msUntilOldestExpiresassumesthis.spawnTimestamps[0]exists when the rate limit is hit. This is safe today because theifguard ensuresspawnTimestamps.length >= SPAWN_RATE_LIMIT(which is 5, > 0). However, thefilter()on line 417 prunes stale entries before this check, so in theory the array is always non-empty at this point. Still, an explicit guard or comment would make this clearer, since the correctness depends on the invariant thatfilter()leaves at leastSPAWN_RATE_LIMITentries when theiffires.[fixable] - 🔵 style (L431): Line exceeds ~100 characters (
const msUntilOldestExpires = Math.max(100, TaskOrchestrator.SPAWN_RATE_WINDOW_MS - oldestAge + 100);). Consider extracting to a shorter expression for readability.[fixable] - 🟡 regressions (L47):
MAX_SPAWN_DEPTHwas reduced from 50 to 5. This means a single tick chain can only spawn 5 tasks before deferring. Combined withSPAWN_RATE_LIMIT = 5per 60-second window, this means that if a goal has many spawn-policy subtasks (e.g. 20), only 5 will be spawned in the first minute. This is a deliberate safety improvement, but the two constants being equal (both 5) meansMAX_SPAWN_DEPTHis effectively redundant — the rate limiter will always prevent a 6th spawn in the same tick chain. Consider documenting that the depth limit is the per-tick-chain ceiling while the rate limit is the cross-chain ceiling, or unifying them.
server/__tests__/task-orchestrator.test.ts
Solid incident fix — the ID namespace mismatch root cause, grace period, rate limiter, and kill switch are well-designed and thoroughly tested. Minor findings around mobile CSS scoping and defensive clarity in the rate limiter.
- 🔵 missing_tests: No test verifies that
setSpawnEnabledcan be toggled tofalsewhile spawned tasks are in-flight, confirming that already-spawned tasks continue (aren't killed) but no new spawns occur. The 'can be toggled while loop is running' test only covers disabled→enabled, not the reverse direction mid-spawn.[fixable]
packages/client/src/store.ts
Solid incident fix — the ID namespace mismatch root cause, grace period, rate limiter, and kill switch are well-designed and thoroughly tested. Minor findings around mobile CSS scoping and defensive clarity in the rate limiter.
- 🔵 bugs (L583):
setSpawnEnabled()calls the API but does not optimistically update localloopStatus.spawnEnabled. The UI relies on the server's WebSocket broadcast to reflect the change. This is fine for correctness (other loop actions follow the same pattern), but it means the toggle button state won't visually flip until the WS broadcast arrives — a brief flash of the old state. Minor UX nit.[fixable]
Reviewed at a59e82f
| background: var(--border); | ||
| } | ||
|
|
||
| .cc-section-action-btn.cc-spawn-enabled { |
There was a problem hiding this comment.
🔵 style: Spawn button color styles (.cc-spawn-enabled/.cc-spawn-disabled) are only defined inside the @media (min-width: 768px) desktop media query. On mobile viewports, the spawn toggle button will render unstyled (inheriting the base .cc-section-action-btn color), so the enabled/disabled visual distinction is lost. Since Mitzo is described as mobile-first, consider adding these color rules to global.css or to a non-media-scoped block. [fixable]
| // Schedule a retry after the oldest entry expires so tasks don't stall | ||
| if (!this.rateLimitRetryTimer) { | ||
| const oldestAge = now - this.spawnTimestamps[0]; | ||
| const msUntilOldestExpires = Math.max(100, TaskOrchestrator.SPAWN_RATE_WINDOW_MS - oldestAge + 100); |
There was a problem hiding this comment.
🟡 unsafe_assumptions: msUntilOldestExpires assumes this.spawnTimestamps[0] exists when the rate limit is hit. This is safe today because the if guard ensures spawnTimestamps.length >= SPAWN_RATE_LIMIT (which is 5, > 0). However, the filter() on line 417 prunes stale entries before this check, so in theory the array is always non-empty at this point. Still, an explicit guard or comment would make this clearer, since the correctness depends on the invariant that filter() leaves at least SPAWN_RATE_LIMIT entries when the if fires. [fixable]
| // Schedule a retry after the oldest entry expires so tasks don't stall | ||
| if (!this.rateLimitRetryTimer) { | ||
| const oldestAge = now - this.spawnTimestamps[0]; | ||
| const msUntilOldestExpires = Math.max(100, TaskOrchestrator.SPAWN_RATE_WINDOW_MS - oldestAge + 100); |
There was a problem hiding this comment.
🔵 style: Line exceeds ~100 characters (const msUntilOldestExpires = Math.max(100, TaskOrchestrator.SPAWN_RATE_WINDOW_MS - oldestAge + 100);). Consider extracting to a shorter expression for readability. [fixable]
| export class TaskOrchestrator { | ||
| /** Maximum concurrent spawn dispatches per tick chain to prevent runaway loops. */ | ||
| private static readonly MAX_SPAWN_DEPTH = 50; | ||
| private static readonly MAX_SPAWN_DEPTH = 5; |
There was a problem hiding this comment.
🟡 regressions: MAX_SPAWN_DEPTH was reduced from 50 to 5. This means a single tick chain can only spawn 5 tasks before deferring. Combined with SPAWN_RATE_LIMIT = 5 per 60-second window, this means that if a goal has many spawn-policy subtasks (e.g. 20), only 5 will be spawned in the first minute. This is a deliberate safety improvement, but the two constants being equal (both 5) means MAX_SPAWN_DEPTH is effectively redundant — the rate limiter will always prevent a 6th spawn in the same tick chain. Consider documenting that the depth limit is the per-tick-chain ceiling while the rate limit is the cross-chain ceiling, or unifying them.
| await api.stopLoop(); | ||
| }, | ||
|
|
||
| async setSpawnEnabled(enabled: boolean) { |
There was a problem hiding this comment.
🔵 bugs: setSpawnEnabled() calls the API but does not optimistically update local loopStatus.spawnEnabled. The UI relies on the server's WebSocket broadcast to reflect the change. This is fine for correctness (other loop actions follow the same pattern), but it means the toggle button state won't visually flip until the WS broadcast arrives — a brief flash of the old state. Minor UX nit. [fixable]
f5b8270 to
bef5ce3
Compare
…safety Root cause: ID namespace mismatch in orphan detection — getActiveSessionIds() returned SDK sessionIds (UUIDs) but tasks stored clientIds (headless:xxx), causing every spawned task to look orphaned and triggering infinite respawns. Safety layers: - Fix ID namespace to use clientIds consistently - Grace period (60s) before orphan detection can reclaim recently-spawned tasks - Spawn depth limit (5 per tick chain) prevents runaway in single tick - Global rate limiter (5 per 60s window) caps cross-chain spawning - Kill switch (spawnEnabled flag, default OFF) with UI toggle - Clear spawn tracking state on start()/stop() to prevent cross-goal leakage Client propagation: - spawnEnabled added to LoopStatus type, protocol parser, store, and API client - Optimistic UI update for instant toggle feedback - CSS classes in global scope for mobile visibility Tests: 82 passing (orchestrator + route endpoint tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
bef5ce3 to
14a6c21
Compare
PR #441's squash merge was based on a branch that predated PR #404, silently reverting three CSS fixes: - .chat-header: lost position:relative, z-index:20, box-shadow - .chat-messages: top padding reverted (content bleeds under header) - .session-banner: background reverted to undefined --bg-primary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR #441's squash merge was based on a branch that predated PR #404, silently reverting three CSS fixes: - .chat-header: lost position:relative, z-index:20, box-shadow - .chat-messages: top padding reverted (content bleeds under header) - .session-banner: background reverted to undefined --bg-primary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(task-board): add spawn kill switch to mobile task board page The toggle was only in TaskBoardSection (desktop collapsible view). The mobile full-page TaskBoard at pages/TaskBoard.tsx was missing it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): restore opaque header + session banner from PR #404 PR #441's squash merge was based on a branch that predated PR #404, silently reverting three CSS fixes: - .chat-header: lost position:relative, z-index:20, box-shadow - .chat-messages: top padding reverted (content bleeds under header) - .session-banner: background reverted to undefined --bg-primary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Root Cause
active,spawnSession()fires async (fire-and-forget)tick()runs orphan detection — checksgetActiveSessionIds()against registrysession_idnot in active set → classified as orphanpending→getNextExecutablefinds it → spawns another sessionMAX_SPAWN_DEPTH=50was the only guard — and it reset between tick chainsSafety Layers
recentSpawnsmap — orphan detection skips tasks spawned within windowMAX_SPAWN_DEPTHper tick chainspawnTimestampssliding window — blocks spawns across all tick chainsTest plan
🤖 Generated with Claude Code