Skip to content

Commit 8593e34

Browse files
authored
Merge pull request #928 from web3dev1337/fix/workspace-restore-origin
fix: restore workspaces after cold restart
2 parents ded65b1 + 75dca98 commit 8593e34

4 files changed

Lines changed: 148 additions & 47 deletions

File tree

client/app.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,34 @@ class ClaudeOrchestrator {
183183
return type === 'server' || /-server$/.test(sid);
184184
}
185185

186+
async maybePlanWorkspaceRecovery(workspaceId, { interactive = true } = {}) {
187+
const targetWorkspaceId = String(workspaceId || '').trim();
188+
const DashboardCtor = window.Dashboard || (typeof Dashboard !== 'undefined' ? Dashboard : null);
189+
if (!this.dashboard && DashboardCtor) {
190+
this.dashboard = new DashboardCtor(this);
191+
}
192+
if (!targetWorkspaceId || !this.dashboard?.planRecoveryForWorkspace) {
193+
return null;
194+
}
195+
196+
const pendingWorkspaceId = String(this.dashboard?.pendingRecovery?.workspaceId || '').trim();
197+
if (pendingWorkspaceId && pendingWorkspaceId === targetWorkspaceId) {
198+
return this.dashboard.pendingRecovery;
199+
}
200+
201+
try {
202+
const recoveryPlan = await this.dashboard.planRecoveryForWorkspace(targetWorkspaceId, { interactive });
203+
if (recoveryPlan?.pending) {
204+
this.dashboard.pendingRecovery = recoveryPlan.pending;
205+
return recoveryPlan.pending;
206+
}
207+
} catch (error) {
208+
console.warn('Failed to prepare workspace recovery plan', { workspaceId: targetWorkspaceId, error });
209+
}
210+
211+
return null;
212+
}
213+
186214
isMainlineBranch(branch) {
187215
const raw = String(branch || '').trim().toLowerCase();
188216
if (!raw) return true;
@@ -1575,6 +1603,10 @@ class ClaudeOrchestrator {
15751603
}
15761604
}
15771605

1606+
if (active) {
1607+
await this.maybePlanWorkspaceRecovery(active.id, { interactive: true });
1608+
}
1609+
15781610
// Update voice command context with workspace info
15791611
this.updateVoiceContext();
15801612
this.applySimpleModeConfig();
@@ -1642,6 +1674,7 @@ class ClaudeOrchestrator {
16421674
// visibility state that was just restored from the tab (fix #786).
16431675
this.lastSessionsWorkspaceId = nextWorkspace.id;
16441676

1677+
await this.maybePlanWorkspaceRecovery(nextWorkspace.id, { interactive: true });
16451678
this.handleInitialSessions(sessions);
16461679

16471680
// Update workspace switcher
@@ -1669,6 +1702,7 @@ class ClaudeOrchestrator {
16691702
// Pre-fetch worktree-specific configs for all terminals
16701703
await this.prefetchWorktreeConfigs(nextWorkspace, sessions);
16711704

1705+
await this.maybePlanWorkspaceRecovery(nextWorkspace.id, { interactive: true });
16721706
// Rebuild with new workspace sessions
16731707
// Terminals will now register to the correct tab via currentTabId
16741708
this.handleInitialSessions(sessions);

client/dashboard.js

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3795,52 +3795,12 @@ class Dashboard {
37953795
async openWorkspace(workspaceId) {
37963796
console.log('Opening workspace:', workspaceId);
37973797

3798-
// Get recovery settings
3799-
const recoverySettings = this.orchestrator.userSettings?.global?.sessionRecovery || {};
3800-
const recoveryEnabled = recoverySettings.enabled !== false;
3801-
const recoveryMode = recoverySettings.mode || 'ask';
3802-
3803-
// Check for recovery state first (if enabled)
3804-
if (recoveryEnabled) {
3805-
const recoveryInfo = await this.checkRecoveryState(workspaceId);
3806-
if (recoveryInfo && recoveryInfo.recoverableSessions > 0) {
3807-
// If the user previously dismissed this exact snapshot, don't nag again.
3808-
const savedAt = String(recoveryInfo.savedAt || '').trim();
3809-
const dismissKey = `orchestrator-recovery-dismissed:${workspaceId}`;
3810-
let dismissedSnapshot = false;
3811-
if (savedAt) {
3812-
try {
3813-
const dismissedAt = String(localStorage.getItem(dismissKey) || '').trim();
3814-
if (dismissedAt && dismissedAt === savedAt) {
3815-
console.log('Skipping recovery dialog - dismissed for this snapshot');
3816-
dismissedSnapshot = true;
3817-
} else {
3818-
// Clear stale dismiss markers when the snapshot changes.
3819-
if (dismissedAt) localStorage.removeItem(dismissKey);
3820-
}
3821-
} catch {
3822-
// ignore
3823-
}
3824-
}
3825-
3826-
if (dismissedSnapshot) {
3827-
// Do nothing: proceed to open workspace with no recovery.
3828-
} else if (recoveryMode === 'auto') {
3829-
// Auto-recover all sessions
3830-
this.pendingRecovery = { workspaceId, mode: 'all', sessions: recoveryInfo.sessions };
3831-
console.log('Auto-recovering all sessions');
3832-
} else if (recoveryMode === 'ask') {
3833-
// Show recovery dialog and wait for user choice
3834-
const shouldRecover = await this.showRecoveryDialog(workspaceId, recoveryInfo);
3835-
if (shouldRecover === 'cancel') {
3836-
return; // User cancelled
3837-
}
3838-
this.pendingRecovery = shouldRecover && typeof shouldRecover === 'object'
3839-
? { workspaceId, ...shouldRecover }
3840-
: shouldRecover;
3841-
}
3842-
// If mode === 'skip', don't set pendingRecovery
3843-
}
3798+
const recoveryPlan = await this.planRecoveryForWorkspace(workspaceId, { interactive: true });
3799+
if (recoveryPlan?.action === 'cancel') {
3800+
return;
3801+
}
3802+
if (recoveryPlan?.pending) {
3803+
this.pendingRecovery = recoveryPlan.pending;
38443804
}
38453805

38463806
// Show loading state
@@ -3866,6 +3826,61 @@ class Dashboard {
38663826
this.orchestrator.socket.emit('switch-workspace', { workspaceId });
38673827
}
38683828

3829+
async planRecoveryForWorkspace(workspaceId, { interactive = true } = {}) {
3830+
const targetWorkspaceId = String(workspaceId || '').trim();
3831+
if (!targetWorkspaceId) {
3832+
return { action: 'skip', pending: null };
3833+
}
3834+
3835+
const recoverySettings = this.orchestrator.userSettings?.global?.sessionRecovery || {};
3836+
const recoveryEnabled = recoverySettings.enabled !== false;
3837+
const recoveryMode = recoverySettings.mode || 'ask';
3838+
if (!recoveryEnabled) {
3839+
return { action: 'skip', pending: null };
3840+
}
3841+
3842+
const recoveryInfo = await this.checkRecoveryState(targetWorkspaceId);
3843+
if (!(recoveryInfo && recoveryInfo.recoverableSessions > 0)) {
3844+
return { action: 'skip', pending: null };
3845+
}
3846+
3847+
const savedAt = String(recoveryInfo.savedAt || '').trim();
3848+
const dismissKey = `orchestrator-recovery-dismissed:${targetWorkspaceId}`;
3849+
if (savedAt) {
3850+
try {
3851+
const dismissedAt = String(localStorage.getItem(dismissKey) || '').trim();
3852+
if (dismissedAt && dismissedAt === savedAt) {
3853+
console.log('Skipping recovery dialog - dismissed for this snapshot');
3854+
return { action: 'dismissed', pending: null };
3855+
}
3856+
if (dismissedAt) localStorage.removeItem(dismissKey);
3857+
} catch {
3858+
// ignore
3859+
}
3860+
}
3861+
3862+
if (recoveryMode === 'auto') {
3863+
console.log('Auto-recovering all sessions');
3864+
return {
3865+
action: 'recover',
3866+
pending: { workspaceId: targetWorkspaceId, mode: 'all', sessions: recoveryInfo.sessions }
3867+
};
3868+
}
3869+
3870+
if (recoveryMode === 'ask' && interactive) {
3871+
const shouldRecover = await this.showRecoveryDialog(targetWorkspaceId, recoveryInfo);
3872+
if (shouldRecover === 'cancel') {
3873+
return { action: 'cancel', pending: null };
3874+
}
3875+
const pending = shouldRecover && typeof shouldRecover === 'object'
3876+
? { workspaceId: targetWorkspaceId, ...shouldRecover }
3877+
: null;
3878+
return pending ? { action: 'recover', pending } : { action: 'skip', pending: null };
3879+
}
3880+
3881+
return { action: 'skip', pending: null };
3882+
}
3883+
38693884
showCreateWorkspaceWizard(options = {}) {
38703885
console.log('Opening workspace creation wizard...');
38713886
if (!window.WorkspaceWizard) {

server/index.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4536,6 +4536,13 @@ app.get('/api/recovery/:workspaceId', async (req, res) => {
45364536
allowSessionIds: allowSessionIds.length ? allowSessionIds : null,
45374537
pruneMissing: true
45384538
});
4539+
const pendingRecoverySessions = Array.isArray(recoveryInfo?.sessions)
4540+
? recoveryInfo.sessions.filter((entry) => {
4541+
const sessionId = String(entry?.sessionId || '').trim();
4542+
if (!sessionId) return false;
4543+
return !sessionManager.hasSessionHydrated(sessionId, { workspaceId });
4544+
})
4545+
: [];
45394546

45404547
let configuredWorktreeCount = 0;
45414548
if (workspace) {
@@ -4558,8 +4565,14 @@ app.get('/api/recovery/:workspaceId', async (req, res) => {
45584565

45594566
res.json({
45604567
...recoveryInfo,
4568+
recoverableSessions: pendingRecoverySessions.length,
4569+
sessions: pendingRecoverySessions,
45614570
configuredTerminalCount: allowSessionIds.length,
4562-
configuredWorktreeCount
4571+
configuredWorktreeCount,
4572+
recoveryAlreadyAppliedCount: Math.max(
4573+
0,
4574+
Number(recoveryInfo?.recoverableSessions || 0) - pendingRecoverySessions.length
4575+
)
45634576
});
45644577
} catch (error) {
45654578
logger.error('Failed to get recovery info', { error: error.message });

server/sessionManager.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class SessionManager extends EventEmitter {
7575
// Keep inactive workspaces' sessions alive (PTYs keep running), keyed by workspace id.
7676
// The active workspace is always `this.workspace`, and its sessions live in `this.sessions`.
7777
this.workspaceSessionMaps = new Map(); // workspaceId -> Map(sessionId -> session)
78+
this.recoveryHydratedSessions = new Set(); // `${workspaceId}::${sessionId}` after the current server boot
7879
this.statusDetector = null; // Will be set later
7980
this.gitHelper = null; // Will be set later
8081
this.fileWatchers = new Map(); // Store file watchers for .git/HEAD files
@@ -106,6 +107,34 @@ class SessionManager extends EventEmitter {
106107
this.worktrees = [];
107108
}
108109

110+
getRecoveryHydrationKey(sessionId, { workspaceId = null, session = null } = {}) {
111+
const sid = String(sessionId || '').trim();
112+
if (!sid) return null;
113+
const resolvedSession = session || this.getSessionById(sid, { workspaceId }) || null;
114+
const ws = String(workspaceId || resolvedSession?.workspace || '').trim();
115+
if (!ws) return null;
116+
return `${ws}::${sid}`;
117+
}
118+
119+
markSessionHydrated(sessionId, { workspaceId = null, session = null } = {}) {
120+
const key = this.getRecoveryHydrationKey(sessionId, { workspaceId, session });
121+
if (!key) return false;
122+
this.recoveryHydratedSessions.add(key);
123+
return true;
124+
}
125+
126+
clearSessionHydrated(sessionId, { workspaceId = null, session = null } = {}) {
127+
const key = this.getRecoveryHydrationKey(sessionId, { workspaceId, session });
128+
if (!key) return false;
129+
return this.recoveryHydratedSessions.delete(key);
130+
}
131+
132+
hasSessionHydrated(sessionId, { workspaceId = null, session = null } = {}) {
133+
const key = this.getRecoveryHydrationKey(sessionId, { workspaceId, session });
134+
if (!key) return false;
135+
return this.recoveryHydratedSessions.has(key);
136+
}
137+
109138
// Determine effective inactivity timeout per session (ms)
110139
getSessionTimeout(session) {
111140
if (!session) return this.sessionTimeout;
@@ -958,6 +987,7 @@ class SessionManager extends EventEmitter {
958987
// Add workspace ID to session
959988
session.workspace = this.workspace?.id || null;
960989
this.sessions.set(sessionId, session);
990+
this.clearSessionHydrated(sessionId, { workspaceId: session.workspace, session });
961991

962992
if (session.workspace) {
963993
sessionRecoveryService.updateSession(session.workspace, sessionId, {
@@ -1768,6 +1798,14 @@ class SessionManager extends EventEmitter {
17681798
}
17691799

17701800
const sanitizedInput = this.sanitizeInputData(data);
1801+
const hasMeaningfulInput = Array.from(sanitizedInput).some((char) => (
1802+
char === '\r' ||
1803+
char === '\n' ||
1804+
(char.length === 1 && char.charCodeAt(0) >= 32)
1805+
));
1806+
if (hasMeaningfulInput) {
1807+
this.markSessionHydrated(sessionId, { workspaceId: session.workspace, session });
1808+
}
17711809
for (const char of sanitizedInput) {
17721810
if (char === '\r' || char === '\n') {
17731811
const command = session.currentCommand.trim();
@@ -2510,6 +2548,7 @@ class SessionManager extends EventEmitter {
25102548
if (!session) return;
25112549

25122550
logger.info('Terminating session', { sessionId: sid, workspaceId: session.workspace || ws || null });
2551+
this.clearSessionHydrated(sid, { workspaceId: session.workspace || ws || null, session });
25132552

25142553
// Clear the inactivity timer to prevent infinite loops
25152554
if (session.inactivityTimer) {

0 commit comments

Comments
 (0)