Compare commits

...

7 Commits

Author SHA1 Message Date
AndyMik90 37e95e8b09 fix(terminal): capture generation after clearAutoResumeQueue to prevent self-cancel
resumeAllPendingClaude captured autoResumeGeneration before calling
clearAutoResumeQueue(), which increments the generation counter. This
caused the generation mismatch check to always evaluate true, aborting
the function before resuming any terminals. Move the capture to after
the clear call so the function only aborts on genuinely new external
cancellations.

Also documents the implicit mutex invariant between the active-terminal
effect, processAutoResumeQueue, and resumeAllPendingClaude.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:38:06 +01:00
AndyMik90 d613df36db fix(terminal): add cancellation mechanism to resumeAllPendingClaude
Addresses CodeRabbit finding (NITPICK/TRIVIAL severity):

[CMT-CR-NITPICK] resumeAllPendingClaude lacked a cancellation mechanism:
- Captured generation counter before clearAutoResumeQueue() call
- Added generation mismatch checks before each iteration
- Added generation check after stagger delay await points
- Ensures early exit when project switches during processing
- Prevents unnecessary stagger delays for stale resume operations
- Consistent with processAutoResumeQueue cancellation pattern

This prevents the function from burning through stagger delays (up to n×500ms)
after a project switch, and ensures responsive handling of project changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:13 +01:00
AndyMik90 6d6b92570e fix(terminal): resolve auto-resume queue race conditions and error handling
Addresses all 4 HIGH severity findings from PR review:

1. [NEW-RC-001/CMT-SENTRY-001] Fixed autoResumeProcessing permanently
   jamming after clearAutoResumeQueue() interrupts processing:
   - Reset autoResumeProcessing=false in clearAutoResumeQueue() after
     incrementing generation counter
   - Safe because stale runs check generation on each iteration
   - Prevents permanent queue jam when project switches during auto-resume

2. [NEW-RC-002/CMT-CR-001] Added missing error handling in
   processAutoResumeQueue:
   - Wrapped resumeTerminalClaudeSession() in try/catch
   - Added try/finally around entire function body
   - Ensures autoResumeProcessing is always reset, even on exceptions

3. [NEW-RC-003/CMT-CR-002] Fixed stale snapshot in resumeAllPendingClaude:
   - Re-check terminal.pendingClaudeResume from store before each resume
   - Consistent with processAutoResumeQueue guard logic
   - Prevents duplicate IPC calls during stagger delays

All changes maintain backward compatibility and existing behavior while
eliminating race conditions and error propagation issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:13 +01:00
AndyMik90 6d2b53ec6a fix: address critical race conditions in auto-resume queue
1. Remove autoResumeProcessing reset in clearAutoResumeQueue()
   - Setting the flag to false created a race condition allowing concurrent
     processAutoResumeQueue() instances
   - The generation counter is sufficient to abort stale processing runs
   - Fixes potential duplicate resume attempts

2. Add error handling in resumeAllPendingClaude()
   - Wrap resumeTerminalClaudeSession() in try/catch
   - Ensures one terminal's error doesn't block remaining terminals
   - Improves robustness of manual "Resume All" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:12 +01:00
AndyMik90 cb1363c994 fix: address new review findings for PR #1835 - race conditions and stale closures
Fixes identified in follow-up review (2026-02-15 18:31 UTC):

HIGH PRIORITY:
- [Gemini HIGH] Remove hasAttemptedAutoResumeRef from onCreated enqueue logic
  Previously setting the ref when enqueuing prevented immediate resume if terminal
  became active before queue processed. Now only active terminal resume sets the ref.

- [CodeRabbit MAJOR] Add generation counter to prevent race conditions
  When clearAutoResumeQueue() is called during processAutoResumeQueue() await,
  the old instance could consume newly enqueued items. Generation counter now
  aborts stale processing runs.

MEDIUM PRIORITY:
- [NEW-001 MEDIUM] Generation counter also fixes clearAutoResumeQueue race
  Same fix as above - prevents cross-run race conditions.

- [CMT-001 LOW] Add concurrency guard to resumeAllPendingClaude
  Rapid clicks could cause concurrent executions with duplicate IPC calls.
  Added isResumingAll flag with try/finally pattern.

LOW PRIORITY:
- [NEW-003 LOW] Fix stale isActive closure in onCreated callback
  Read activeTerminalId from store instead of using render-time closure value
  to ensure accurate active state check.

Technical details:
- autoResumeGeneration counter incremented on clearAutoResumeQueue()
- processAutoResumeQueue() captures generation, checks after each await
- isResumingAll concurrency guard added with proper try/finally cleanup
- onCreated reads current active state from store, not closure
- Removed hasAttemptedAutoResumeRef.current usage from enqueue path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:12 +01:00
AndyMik90 50aa15e11a fix: address review findings for PR #1835
- Add clearAutoResumeQueue() to resumeAllPendingClaude to prevent redundant processing
- Extract shared resumeTerminalClaudeSession() helper to eliminate code duplication
- Use AUTO_RESUME_STAGGER_MS constant consistently for stagger delays

Fixes:
- MEDIUM: resumeAllPendingClaude now clears auto-resume queue to coordinate with automatic processing
- LOW: Shared helper function ensures consistent resume behavior across both code paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:12 +01:00
AndyMik90 c984580386 feat(terminal): add centralized auto-resume queue for staggered session recovery
When the app restarts, only the active terminal auto-resumes its Claude
session. All other terminals show a pending resume badge and require
manual activation. This is because the deferred resume useEffect in
Terminal.tsx gates on isActive === true.

Add a module-level auto-resume queue coordinator in terminal-store.ts
that processes non-active terminals with staggered delays (1.5s initial,
500ms between each). Terminals enqueue themselves in the onCreated
callback when their PTY is ready and they have a pending resume. Active
terminals dequeue themselves since they handle their own resume. The
queue is cleared on new restore operations to prevent stale entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:37:12 +01:00
2 changed files with 198 additions and 22 deletions
@@ -3,7 +3,7 @@ import { useDroppable, useDndContext } from '@dnd-kit/core';
import '@xterm/xterm/css/xterm.css';
import { FileDown } from 'lucide-react';
import { cn } from '../lib/utils';
import { useTerminalStore } from '../stores/terminal-store';
import { useTerminalStore, enqueueAutoResume, dequeueAutoResume } from '../stores/terminal-store';
import { useSettingsStore } from '../stores/settings-store';
import { useToast } from '../hooks/use-toast';
import type { TerminalProps } from './terminal/types';
@@ -381,6 +381,17 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
}
pendingWorktreeConfigRef.current = null;
}
// Auto-resume: enqueue non-active terminals for staggered resume
// Read current active state from store to avoid stale closure value
const currentActiveId = useTerminalStore.getState().activeTerminalId;
const isCurrentlyActive = currentActiveId === id;
if (!isCurrentlyActive) {
const currentTerminal = useTerminalStore.getState().terminals.find(t => t.id === id);
if (currentTerminal?.pendingClaudeResume) {
enqueueAutoResume(id);
}
}
},
onError: (error) => {
// Clear pending config on error to prevent stale config from being applied
@@ -573,6 +584,8 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// Check if both conditions are met for auto-resume
if (isActive && terminal?.pendingClaudeResume) {
// Remove from queue since active terminal handles its own resume
dequeueAutoResume(id);
// Defer the resume slightly to ensure all React state updates have propagated
// This fixes the race condition where isActive and pendingClaudeResume might update
// at different times during the restoration flow
@@ -630,6 +643,7 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
return () => {
isMountedRef.current = false;
dequeueAutoResume(id);
cleanupAutoNaming();
// Clear post-creation dimension check timeout to prevent operations on unmounted component
@@ -116,6 +116,124 @@ export function writeToTerminal(terminalId: string, data: string): void {
}
}
// === Auto-Resume Queue Coordinator ===
// Coordinates staggered auto-resume of non-active terminals after app restart.
// Each terminal enqueues itself when its PTY is confirmed ready (onCreated).
// A single coordinator processes the queue with stagger delays.
const AUTO_RESUME_INITIAL_DELAY_MS = 1500;
const AUTO_RESUME_STAGGER_MS = 500;
// Auto-resume queue state (single-threaded JS assumption - see processAutoResumeQueue)
//
// Mutex invariant: The active-terminal effect (which triggers enqueueAutoResume) and
// processAutoResumeQueue / resumeAllPendingClaude are mutually coordinated through
// clearAutoResumeQueue(). When a project switch or "Resume All" occurs, clearAutoResumeQueue()
// cancels any pending timer, empties the queue, and bumps autoResumeGeneration to abort
// any in-flight processing run. This ensures only one resumption path is active at a time.
let autoResumeQueue: string[] = [];
let autoResumeTimer: ReturnType<typeof setTimeout> | null = null;
let autoResumeProcessing = false;
let autoResumeGeneration = 0; // Generation counter to abort stale processing runs
let isResumingAll = false; // Concurrency guard for resumeAllPendingClaude
export function enqueueAutoResume(terminalId: string): void {
if (autoResumeQueue.includes(terminalId)) return;
autoResumeQueue.push(terminalId);
debugLog(`[AutoResume] Enqueued terminal: ${terminalId}, queue size: ${autoResumeQueue.length}`);
// Start initial delay timer on first enqueue only
if (autoResumeTimer === null && !autoResumeProcessing) {
debugLog(`[AutoResume] Starting initial delay (${AUTO_RESUME_INITIAL_DELAY_MS}ms)`);
autoResumeTimer = setTimeout(() => {
autoResumeTimer = null;
processAutoResumeQueue();
}, AUTO_RESUME_INITIAL_DELAY_MS);
}
}
export function dequeueAutoResume(terminalId: string): void {
const idx = autoResumeQueue.indexOf(terminalId);
if (idx !== -1) {
autoResumeQueue.splice(idx, 1);
debugLog(`[AutoResume] Dequeued terminal: ${terminalId}, queue size: ${autoResumeQueue.length}`);
}
}
export function clearAutoResumeQueue(): void {
autoResumeQueue = [];
if (autoResumeTimer !== null) {
clearTimeout(autoResumeTimer);
autoResumeTimer = null;
}
// Increment generation first to abort any in-flight processing
autoResumeGeneration++;
// Reset processing flag so new enqueue attempts can start a fresh processor
// Safe to do after generation bump because stale runs check generation on each iteration
autoResumeProcessing = false;
debugLog('[AutoResume] Queue cleared');
}
/**
* Shared helper to resume a terminal's Claude session with consistent behavior.
* Clears the pending flag and triggers IPC activation.
*/
function resumeTerminalClaudeSession(terminalId: string): void {
useTerminalStore.getState().setPendingClaudeResume(terminalId, false);
window.electronAPI.activateDeferredClaudeResume(terminalId);
}
async function processAutoResumeQueue(): Promise<void> {
if (autoResumeProcessing) return;
autoResumeProcessing = true;
const generation = autoResumeGeneration; // Capture generation to detect cancellation
debugLog(`[AutoResume] Processing queue (generation ${generation}), ${autoResumeQueue.length} terminals`);
try {
while (autoResumeQueue.length > 0) {
// Check if this processing run has been cancelled
if (generation !== autoResumeGeneration) {
debugLog(`[AutoResume] Generation mismatch (${generation} !== ${autoResumeGeneration}) — aborting stale processing`);
return;
}
const terminalId = autoResumeQueue.shift()!;
// Check if terminal still needs resume
const terminal = useTerminalStore.getState().terminals.find(t => t.id === terminalId);
if (!terminal?.pendingClaudeResume) {
debugLog(`[AutoResume] Skipping ${terminalId} — no longer pending`);
continue;
}
try {
debugLog(`[AutoResume] Resuming terminal: ${terminalId}`);
resumeTerminalClaudeSession(terminalId);
} catch (error) {
// Log error and continue processing remaining terminals
debugError(`[AutoResume] Error resuming terminal ${terminalId}:`, error);
}
// Stagger delay between resumes
if (autoResumeQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS));
// Re-check generation after await (may have been cancelled during stagger delay)
if (generation !== autoResumeGeneration) {
debugLog(`[AutoResume] Generation mismatch after stagger — aborting stale processing`);
return;
}
}
}
debugLog('[AutoResume] Queue processing complete');
} finally {
// Only reset processing flag if this is still the current generation
if (generation === autoResumeGeneration) {
autoResumeProcessing = false;
}
}
}
export type TerminalStatus = 'idle' | 'running' | 'claude-active' | 'exited';
export interface Terminal {
@@ -555,35 +673,74 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
resumeAllPendingClaude: async () => {
const state = get();
// Filter terminals with pending Claude resume
const pendingTerminals = state.terminals.filter(t => t.pendingClaudeResume === true);
if (pendingTerminals.length === 0) {
debugLog('[TerminalStore] No terminals with pending Claude resume');
// Concurrency guard - prevent multiple simultaneous executions
if (isResumingAll) {
debugLog('[TerminalStore] Resume All already in progress — skipping');
return;
}
isResumingAll = true;
debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with 500ms stagger`);
try {
// Clear auto-resume queue to prevent redundant processing
clearAutoResumeQueue();
// Iterate through terminals with staggered delays
for (let i = 0; i < pendingTerminals.length; i++) {
const terminal = pendingTerminals[i];
// Clear the pending flag BEFORE IPC call to prevent race condition
// with auto-resume effect in Terminal.tsx (which checks this flag on a 100ms timeout)
get().setPendingClaudeResume(terminal.id, false);
// Capture generation AFTER clearAutoResumeQueue() bumps it, so this call
// doesn't immediately self-cancel, but CAN be cancelled by subsequent external
// calls to clearAutoResumeQueue() (e.g., project switch)
const generation = autoResumeGeneration;
debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`);
window.electronAPI.activateDeferredClaudeResume(terminal.id);
const state = get();
// Wait 500ms before processing next terminal (staggered delay)
if (i < pendingTerminals.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
// Filter terminals with pending Claude resume
const pendingTerminals = state.terminals.filter(t => t.pendingClaudeResume === true);
if (pendingTerminals.length === 0) {
debugLog('[TerminalStore] No terminals with pending Claude resume');
return;
}
}
debugLog('[TerminalStore] Completed resuming all pending Claude sessions');
debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with ${AUTO_RESUME_STAGGER_MS}ms stagger (generation ${generation})`);
// Iterate through terminals with staggered delays
for (let i = 0; i < pendingTerminals.length; i++) {
// Check for cancellation (e.g., project switch triggered clearAutoResumeQueue)
if (generation !== autoResumeGeneration) {
debugLog(`[TerminalStore] Generation mismatch in resumeAll (${generation} !== ${autoResumeGeneration}) — aborting`);
return;
}
const terminal = pendingTerminals[i];
// Re-check terminal still needs resume (may have changed during stagger delay)
const currentTerminal = get().terminals.find(t => t.id === terminal.id);
if (!currentTerminal?.pendingClaudeResume) {
debugLog(`[TerminalStore] Skipping ${terminal.id} — no longer pending`);
continue;
}
try {
debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`);
resumeTerminalClaudeSession(terminal.id);
} catch (error) {
// Log error and continue processing remaining terminals
debugError(`[TerminalStore] Error resuming terminal ${terminal.id}:`, error);
}
// Wait before processing next terminal (staggered delay)
if (i < pendingTerminals.length - 1) {
await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS));
// Re-check generation after await (may have been cancelled during stagger)
if (generation !== autoResumeGeneration) {
debugLog(`[TerminalStore] Generation mismatch after stagger in resumeAll — aborting`);
return;
}
}
}
debugLog('[TerminalStore] Completed resuming all pending Claude sessions');
} finally {
isResumingAll = false;
}
},
getTerminal: (id: string) => {
@@ -628,6 +785,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
return;
}
restoringProjects.add(projectPath);
clearAutoResumeQueue();
try {
const store = useTerminalStore.getState();
@@ -700,3 +858,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
restoringProjects.delete(projectPath);
}
}
// NOTE: HMR cleanup for auto-resume queue state would be beneficial during development
// to clear timers on hot reload, but requires augmenting ImportMeta types in vite-env.d.ts.
// The generation counter provides sufficient protection against stale processing runs.