@sylphx/flow 3.19.1 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Cleanup Handler
3
3
  * Manages graceful cleanup on exit and crash recovery
4
- * Handles process signals and ensures backup restoration
5
4
  *
6
5
  * Centralized cleanup for all exit paths:
7
- * - Signal (SIGINT/SIGTERM) → onSignal() → restore + cleanup
8
- * - Manual (FlowExecutor.cleanup) → cleanup() → restore + cleanup
9
- * - Crash recovery (next startup) → recoverOnStartup() → restore + prune
6
+ * - Signal (SIGTERM) → onSignal() → release + restore + finalize
7
+ * - Manual (FlowExecutor.cleanup) → cleanup() → release + restore + finalize
8
+ * - Crash recovery (next startup) → recoverOnStartup() → restore + finalize
10
9
  *
11
- * IMPORTANT: Node.js 'exit' event handlers MUST be synchronous.
12
- * We use SIGINT/SIGTERM handlers to perform async cleanup before exiting.
13
- * The 'exit' handler is only a last-resort sync cleanup.
10
+ * SIGINT is NOT handled the child process (claude-code) handles its own Ctrl+C.
11
+ * Suppression is done in execute-v2.ts around the child spawn.
12
+ *
13
+ * CRITICAL: backup.json is deleted AFTER restore succeeds (via finalizeSessionCleanup),
14
+ * ensuring orphan detection always works even if the process dies mid-cleanup.
14
15
  */
15
16
 
16
17
  import { existsSync } from 'node:fs';
@@ -51,7 +52,8 @@ export class CleanupHandler {
51
52
  }
52
53
 
53
54
  /**
54
- * Register cleanup hooks for current project
55
+ * Register cleanup hooks for current project.
56
+ * SIGINT is intentionally NOT handled — child handles Ctrl+C.
55
57
  */
56
58
  registerCleanupHooks(projectHash: string): void {
57
59
  if (this.registered) {
@@ -61,43 +63,33 @@ export class CleanupHandler {
61
63
  this.currentProjectHash = projectHash;
62
64
  this.registered = true;
63
65
 
64
- // SIGINT (Ctrl+C) - perform async cleanup then exit
65
- process.on('SIGINT', () => {
66
- this.handleSignal('SIGINT', 0);
67
- });
68
-
69
- // SIGTERM - perform async cleanup then exit
66
+ // SIGTERM perform async cleanup then exit
70
67
  process.on('SIGTERM', () => {
71
68
  this.handleSignal('SIGTERM', 0);
72
69
  });
73
70
 
74
- // Uncaught exceptions - perform async cleanup then exit with error
71
+ // Uncaught exceptions perform async cleanup then exit with error
75
72
  process.on('uncaughtException', (error) => {
76
73
  console.error('\nUncaught Exception:', error);
77
74
  this.handleSignal('uncaughtException', 1);
78
75
  });
79
76
 
80
- // Unhandled rejections - perform async cleanup then exit with error
77
+ // Unhandled rejections perform async cleanup then exit with error
81
78
  process.on('unhandledRejection', (reason) => {
82
79
  console.error('\nUnhandled Rejection:', reason);
83
80
  this.handleSignal('unhandledRejection', 1);
84
81
  });
85
82
 
86
- // 'exit' handler - SYNC ONLY, last resort
87
- // This catches cases where process.exit() was called without going through signals
83
+ // 'exit' handler SYNC ONLY, last resort safety net
88
84
  process.on('exit', () => {
89
- // If cleanup wasn't done via signal handlers, log warning
90
- // (We can't do async cleanup here - just flag it)
91
- if (!this.cleanupCompleted && this.currentProjectHash) {
92
- // Recovery will happen on next startup via recoverOnStartup()
93
- // This is the intended safety net for abnormal exits
94
- }
85
+ // If cleanup wasn't done via signal handlers, orphan recovery
86
+ // will happen on next startup via recoverOnStartup()
95
87
  });
96
88
  }
97
89
 
98
90
  /**
99
- * Handle signal with async cleanup
100
- * Ensures cleanup completes before process exit
91
+ * Handle signal with async cleanup.
92
+ * Ensures cleanup completes before process exit.
101
93
  */
102
94
  private handleSignal(signal: string, exitCode: number): void {
103
95
  // Prevent double cleanup
@@ -108,7 +100,6 @@ export class CleanupHandler {
108
100
 
109
101
  this.cleanupInProgress = true;
110
102
 
111
- // Perform async cleanup, then exit
112
103
  this.onSignal(signal).finally(() => {
113
104
  this.cleanupCompleted = true;
114
105
  process.exit(exitCode);
@@ -116,8 +107,8 @@ export class CleanupHandler {
116
107
  }
117
108
 
118
109
  /**
119
- * Async cleanup for signals and manual cleanup
120
- * Handles session end and backup restoration
110
+ * Async cleanup for signals.
111
+ * Release session restore if last → finalize AFTER restore.
121
112
  */
122
113
  private async onSignal(_signal: string): Promise<void> {
123
114
  if (!this.currentProjectHash) {
@@ -125,15 +116,12 @@ export class CleanupHandler {
125
116
  }
126
117
 
127
118
  try {
128
- const { shouldRestore, session } = await this.sessionManager.endSession(
119
+ const { shouldRestore, backupRef } = await this.sessionManager.releaseSession(
129
120
  this.currentProjectHash
130
121
  );
131
122
 
132
- if (shouldRestore && session) {
133
- await this.backupManager.restoreBackup(this.currentProjectHash, session.sessionId);
134
- await this.backupManager.cleanupOldBackups(this.currentProjectHash, 3);
135
- await this.gitStashManager.popSettingsChanges(session.projectPath);
136
- await this.secretsManager.clearSecrets(this.currentProjectHash);
123
+ if (shouldRestore && backupRef) {
124
+ await this.restoreAndFinalize(this.currentProjectHash, backupRef);
137
125
  }
138
126
  } catch (error) {
139
127
  debug('signal cleanup failed:', error);
@@ -141,24 +129,42 @@ export class CleanupHandler {
141
129
  }
142
130
 
143
131
  /**
144
- * Recover on startup (for all projects)
145
- * Comprehensive cleanup: crashed sessions, stale data, history pruning
146
- * Silent operation - no console output
132
+ * Restore backup and finalize session.
133
+ * Single source of truth for the restore → finalize → cleanup sequence.
147
134
  *
148
- * Performance: Heavy cleanup scans (history pruning, orphaned project detection)
149
- * run at most once per day to avoid slowing down every startup.
135
+ * CRITICAL ordering: backup.json is deleted (via finalizeSessionCleanup) only
136
+ * AFTER restoreBackup succeeds. If restore throws, finalize is NOT called,
137
+ * ensuring orphan detection always works on next startup.
138
+ */
139
+ private async restoreAndFinalize(
140
+ projectHash: string,
141
+ backupRef: { sessionId: string; projectPath: string; target?: string }
142
+ ): Promise<void> {
143
+ await this.backupManager.restoreBackup(projectHash, backupRef.sessionId);
144
+ await this.sessionManager.finalizeSessionCleanup(projectHash);
145
+ await this.backupManager.cleanupOldBackups(projectHash, 3);
146
+ // Clean up any orphaned .flow-restore-* temp dirs from interrupted restores
147
+ if (backupRef.target) {
148
+ await this.backupManager.cleanupOrphanedRestores(backupRef.projectPath, backupRef.target).catch(() => {});
149
+ }
150
+ await this.gitStashManager.popSettingsChanges(backupRef.projectPath);
151
+ await this.secretsManager.clearSecrets(projectHash);
152
+ }
153
+
154
+ /**
155
+ * Recover on startup (for all projects).
156
+ * Handles: orphaned sessions, legacy migration, periodic heavy cleanup.
150
157
  */
151
158
  async recoverOnStartup(): Promise<void> {
159
+ // 0. Migrate legacy session files (old <hash>.json format → new directory format)
160
+ await this.migrateLegacySessions();
161
+
152
162
  // 1. Always recover orphaned sessions (from crashes) — fast when none exist
153
163
  const orphanedSessions = await this.sessionManager.detectOrphanedSessions();
154
164
 
155
- for (const [projectHash, session] of orphanedSessions) {
165
+ for (const [projectHash, backupRef] of orphanedSessions) {
156
166
  try {
157
- await this.backupManager.restoreBackup(projectHash, session.sessionId);
158
- await this.sessionManager.recoverSession(projectHash, session);
159
- await this.gitStashManager.popSettingsChanges(session.projectPath);
160
- await this.secretsManager.clearSecrets(projectHash);
161
- await this.backupManager.cleanupOldBackups(projectHash, 3);
167
+ await this.restoreAndFinalize(projectHash, backupRef);
162
168
  } catch (error) {
163
169
  debug('startup recovery failed for session:', error);
164
170
  }
@@ -178,6 +184,74 @@ export class CleanupHandler {
178
184
  }
179
185
  }
180
186
 
187
+ /**
188
+ * Migrate legacy session files (old format: sessions/<hash>.json)
189
+ * to new directory format (sessions/<hash>/backup.json + pids/)
190
+ */
191
+ private async migrateLegacySessions(): Promise<void> {
192
+ const sessionsDir = path.join(this.projectManager.getFlowHomeDir(), 'sessions');
193
+
194
+ if (!existsSync(sessionsDir)) {
195
+ return;
196
+ }
197
+
198
+ try {
199
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
200
+ const legacyFiles = entries.filter(
201
+ (e) => e.isFile() && e.name.endsWith('.json') && e.name !== '.last-cleanup'
202
+ );
203
+
204
+ for (const file of legacyFiles) {
205
+ const hash = file.name.replace('.json', '');
206
+ const legacyPath = path.join(sessionsDir, file.name);
207
+
208
+ try {
209
+ const content = JSON.parse(await fs.readFile(legacyPath, 'utf-8'));
210
+
211
+ // Only migrate if it has the old session format fields
212
+ if (content.sessionId && content.backupPath && content.cleanupRequired !== undefined) {
213
+ const paths = this.projectManager.getProjectPaths(hash);
214
+
215
+ // Create new directory structure
216
+ await fs.mkdir(paths.pidsDir, { recursive: true });
217
+
218
+ // Write backup.json from old session data (only if cleanup was still required)
219
+ if (content.cleanupRequired) {
220
+ const backupRef = {
221
+ sessionId: content.sessionId,
222
+ backupPath: content.backupPath,
223
+ projectPath: content.projectPath,
224
+ target: content.target,
225
+ createdAt: content.startTime,
226
+ createdByPid: content.pid,
227
+ };
228
+ await fs.writeFile(paths.backupRefFile, JSON.stringify(backupRef, null, 2));
229
+ }
230
+
231
+ // Remove legacy file
232
+ await fs.unlink(legacyPath);
233
+ debug('migrated legacy session %s', hash);
234
+ }
235
+ } catch (error) {
236
+ debug('failed to migrate legacy session %s: %O', hash, error);
237
+ // Move corrupt file aside instead of deleting — preserves data for diagnosis
238
+ try {
239
+ await fs.rename(legacyPath, `${legacyPath}.corrupt`);
240
+ } catch {
241
+ // If rename fails, remove it to prevent retry loops
242
+ try {
243
+ await fs.unlink(legacyPath);
244
+ } catch {
245
+ // Ignore
246
+ }
247
+ }
248
+ }
249
+ }
250
+ } catch (error) {
251
+ debug('legacy migration scan failed:', error);
252
+ }
253
+ }
254
+
181
255
  /**
182
256
  * Check if periodic cleanup should run (at most once per 24 hours)
183
257
  */
@@ -205,31 +279,28 @@ export class CleanupHandler {
205
279
  }
206
280
 
207
281
  /**
208
- * Manually cleanup a specific project session (with multi-session support)
282
+ * Manually cleanup a specific project session.
283
+ * Release → restore if last → finalize AFTER restore.
209
284
  */
210
285
  async cleanup(projectHash: string): Promise<void> {
211
- const { shouldRestore, session } = await this.sessionManager.endSession(projectHash);
212
-
213
- if (shouldRestore && session) {
214
- // Last session - full restore and cleanup
215
- await this.backupManager.restoreBackup(projectHash, session.sessionId);
216
- await this.backupManager.cleanupOldBackups(projectHash, 3);
217
- await this.gitStashManager.popSettingsChanges(session.projectPath);
218
- await this.secretsManager.clearSecrets(projectHash);
286
+ const { shouldRestore, backupRef } = await this.sessionManager.releaseSession(projectHash);
287
+
288
+ if (shouldRestore && backupRef) {
289
+ await this.restoreAndFinalize(projectHash, backupRef);
219
290
  }
220
291
  }
221
292
 
222
293
  /**
223
- * Clean up data for projects whose paths no longer exist
224
- * Detects orphaned project hashes by checking if the original project path is accessible
294
+ * Clean up data for projects whose paths no longer exist.
295
+ * Uses isSessionActive instead of getActiveSession.
225
296
  */
226
297
  private async cleanupOrphanedProjects(): Promise<void> {
227
298
  const allHashes = await this.getAllProjectHashes();
228
299
 
229
300
  for (const hash of allHashes) {
230
301
  // Skip projects with active sessions
231
- const activeSession = await this.sessionManager.getActiveSession(hash);
232
- if (activeSession) {
302
+ const isActive = await this.sessionManager.isSessionActive(hash);
303
+ if (isActive) {
233
304
  continue;
234
305
  }
235
306
 
@@ -267,7 +338,6 @@ export class CleanupHandler {
267
338
  }
268
339
  };
269
340
 
270
- // Scan both directories in parallel
271
341
  const [backupHashes, secretHashes] = await Promise.all([
272
342
  scanDir(path.join(flowHome, 'backups')),
273
343
  scanDir(path.join(flowHome, 'secrets')),
@@ -302,7 +372,7 @@ export class CleanupHandler {
302
372
  }
303
373
 
304
374
  /**
305
- * Remove all stored data for a project hash (backups, secrets, session file)
375
+ * Remove all stored data for a project hash (backups, secrets, session dir)
306
376
  */
307
377
  private async cleanupProjectData(projectHash: string): Promise<void> {
308
378
  const paths = this.projectManager.getProjectPaths(projectHash);
@@ -321,11 +391,11 @@ export class CleanupHandler {
321
391
  debug('failed to remove secrets for %s: %O', projectHash, error);
322
392
  }
323
393
 
324
- // Remove session file if exists
394
+ // Remove session directory (replaces old fs.unlink(sessionFile))
325
395
  try {
326
- await fs.unlink(paths.sessionFile);
396
+ await fs.rm(paths.sessionDir, { recursive: true, force: true });
327
397
  } catch {
328
- // Expected: file may not exist
398
+ // Expected: directory may not exist
329
399
  }
330
400
  }
331
401
  }
@@ -60,6 +60,7 @@ export class FlowExecutor {
60
60
 
61
61
  /**
62
62
  * Try to join an existing session. Returns summary if joined, null otherwise.
63
+ * Always re-applies settings for self-healing (fixes corrupted state from partial restores).
63
64
  */
64
65
  private async tryJoinExistingSession(
65
66
  projectPath: string,
@@ -67,31 +68,39 @@ export class FlowExecutor {
67
68
  target: string,
68
69
  verbose?: boolean
69
70
  ): Promise<{ joined: true } | null> {
70
- const existingSession = await this.sessionManager.getActiveSession(projectHash);
71
- if (!existingSession) {
71
+ // Check if there's an active session (backup.json exists)
72
+ const backupRef = await this.sessionManager.getBackupRef(projectHash);
73
+ if (!backupRef) {
72
74
  return null;
73
75
  }
74
76
 
75
- const targetObj = resolveTargetOrId(target);
76
- const agentsDir = path.join(projectPath, targetObj.config.agentDir);
77
- const filesExist = existsSync(agentsDir) && (await fs.readdir(agentsDir)).length > 0;
78
-
79
- if (filesExist) {
80
- await this.sessionManager.startSession(
81
- projectPath,
82
- projectHash,
83
- target,
84
- existingSession.backupPath
85
- );
86
- this.cleanupHandler.registerCleanupHooks(projectHash);
87
- return { joined: true };
77
+ if (verbose) {
78
+ console.log(chalk.dim('Joining existing session...'));
88
79
  }
89
80
 
90
- if (verbose) {
91
- console.log(chalk.dim('Session files missing, re-attaching...'));
81
+ // Register our PID with the session.
82
+ // Verify backupRef is still valid after acquiring — handles the TOCTOU race
83
+ // where cleanup could delete backup.json between getBackupRef() and acquireSession().
84
+ const result = await this.sessionManager.acquireSession(projectHash, projectPath, target);
85
+ if (!result.backupRef) {
86
+ // Session was cleaned up between our check and acquire — undo our PID registration
87
+ await this.sessionManager.releaseSession(projectHash);
88
+ return null;
92
89
  }
93
- await this.sessionManager.endSession(projectHash);
94
- return null;
90
+
91
+ this.cleanupHandler.registerCleanupHooks(projectHash);
92
+
93
+ // Always re-apply settings for self-healing
94
+ const targetObj = resolveTargetOrId(target);
95
+ if (targetObj.applySettings) {
96
+ try {
97
+ await targetObj.applySettings(projectPath, {});
98
+ } catch (error) {
99
+ debug('applySettings failed during join:', error);
100
+ }
101
+ }
102
+
103
+ return { joined: true };
95
104
  }
96
105
 
97
106
  /**
@@ -128,12 +137,12 @@ export class FlowExecutor {
128
137
  }),
129
138
  ]);
130
139
 
131
- const { session } = await this.sessionManager.startSession(
132
- projectPath,
140
+ // Acquire session with backup info — atomic first-session detection
141
+ const { backupRef } = await this.sessionManager.acquireSession(
133
142
  projectHash,
143
+ projectPath,
134
144
  target,
135
- backup.backupPath,
136
- backup.sessionId
145
+ { sessionId: backup.sessionId, backupPath: backup.backupPath }
137
146
  );
138
147
 
139
148
  this.cleanupHandler.registerCleanupHooks(projectHash);
@@ -142,8 +151,9 @@ export class FlowExecutor {
142
151
  await this.clearUserSettings(projectPath, target);
143
152
  }
144
153
 
154
+ const sessionId = backupRef?.sessionId ?? backup.sessionId;
145
155
  const templates = await this.templateLoader.loadTemplates(target);
146
- const manifest = await this.backupManager.getManifest(projectHash, session.sessionId);
156
+ const manifest = await this.backupManager.getManifest(projectHash, sessionId);
147
157
 
148
158
  if (!manifest) {
149
159
  throw new Error('Backup manifest not found');
@@ -161,7 +171,7 @@ export class FlowExecutor {
161
171
  }
162
172
  }
163
173
 
164
- await this.backupManager.updateManifest(projectHash, session.sessionId, manifest);
174
+ await this.backupManager.updateManifest(projectHash, sessionId, manifest);
165
175
 
166
176
  return {
167
177
  agents: attachResult.agentsAdded.length,
@@ -18,7 +18,9 @@ import { targetManager } from './target-manager.js';
18
18
  const execAsync = promisify(exec);
19
19
 
20
20
  export interface ProjectPaths {
21
- sessionFile: string;
21
+ sessionDir: string; // ~/.sylphx-flow/sessions/<hash>/
22
+ backupRefFile: string; // ~/.sylphx-flow/sessions/<hash>/backup.json
23
+ pidsDir: string; // ~/.sylphx-flow/sessions/<hash>/pids/
22
24
  backupsDir: string;
23
25
  secretsDir: string;
24
26
  latestBackup: string;
@@ -45,12 +47,14 @@ export class ProjectManager {
45
47
  * Get all paths for a project
46
48
  */
47
49
  getProjectPaths(projectHash: string): ProjectPaths {
48
- const sessionsDir = path.join(this.flowHomeDir, 'sessions');
50
+ const sessionDir = path.join(this.flowHomeDir, 'sessions', projectHash);
49
51
  const backupsDir = path.join(this.flowHomeDir, 'backups', projectHash);
50
52
  const secretsDir = path.join(this.flowHomeDir, 'secrets', projectHash);
51
53
 
52
54
  return {
53
- sessionFile: path.join(sessionsDir, `${projectHash}.json`),
55
+ sessionDir,
56
+ backupRefFile: path.join(sessionDir, 'backup.json'),
57
+ pidsDir: path.join(sessionDir, 'pids'),
54
58
  backupsDir,
55
59
  secretsDir,
56
60
  latestBackup: path.join(backupsDir, 'latest'),
@@ -216,19 +220,19 @@ export class ProjectManager {
216
220
  /**
217
221
  * Get all active projects
218
222
  */
219
- async getActiveProjects(): Promise<Array<{ hash: string; sessionFile: string }>> {
223
+ async getActiveProjects(): Promise<Array<{ hash: string; sessionDir: string }>> {
220
224
  const sessionsDir = path.join(this.flowHomeDir, 'sessions');
221
225
 
222
226
  if (!existsSync(sessionsDir)) {
223
227
  return [];
224
228
  }
225
229
 
226
- const files = await fs.readdir(sessionsDir);
227
- return files
228
- .filter((file) => file.endsWith('.json'))
229
- .map((file) => ({
230
- hash: file.replace('.json', ''),
231
- sessionFile: path.join(sessionsDir, file),
230
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
231
+ return entries
232
+ .filter((entry) => entry.isDirectory() && entry.name !== 'history')
233
+ .map((entry) => ({
234
+ hash: entry.name,
235
+ sessionDir: path.join(sessionsDir, entry.name),
232
236
  }));
233
237
  }
234
238
 
@@ -243,7 +247,8 @@ export class ProjectManager {
243
247
  } | null> {
244
248
  const paths = this.getProjectPaths(projectHash);
245
249
 
246
- const hasActiveSession = existsSync(paths.sessionFile);
250
+ // Session is active if backup.json exists in the session directory
251
+ const hasActiveSession = existsSync(paths.backupRefFile);
247
252
 
248
253
  let backupsCount = 0;
249
254
  if (existsSync(paths.backupsDir)) {