|
|
|
@@ -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.
|
|
|
|
|