@sylphx/flow 3.20.0 → 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.
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/src/commands/flow/execute-v2.ts +19 -1
- package/src/core/__tests__/cleanup-handler.test.ts +152 -25
- package/src/core/__tests__/session-cleanup.test.ts +329 -35
- package/src/core/backup-manager.ts +99 -9
- package/src/core/cleanup-handler.ts +134 -64
- package/src/core/flow-executor.ts +35 -25
- package/src/core/project-manager.ts +16 -11
- package/src/core/session-manager.ts +223 -147
- package/src/targets/claude-code.ts +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @sylphx/flow
|
|
2
2
|
|
|
3
|
+
## 3.21.0 (2026-02-08)
|
|
4
|
+
|
|
5
|
+
Add SessionStart memory hook and use stdin for notifications
|
|
6
|
+
|
|
7
|
+
### ✨ Features
|
|
8
|
+
|
|
9
|
+
- **flow:** replace state-flag session management with PID-based ground truth ([692344d](https://github.com/SylphxAI/flow/commit/692344d7cd2b2b8c3b460347b3111ac5b08e375a))
|
|
10
|
+
|
|
11
|
+
### 🐛 Bug Fixes
|
|
12
|
+
|
|
13
|
+
- close three gaps found in challenge round 2 ([52bf789](https://github.com/SylphxAI/flow/commit/52bf789323a30d3a245375bdd4442a2532984823))
|
|
14
|
+
|
|
15
|
+
### ♻️ Refactoring
|
|
16
|
+
|
|
17
|
+
- extract restoreAndFinalize helper, improve cleanup safety ([959f255](https://github.com/SylphxAI/flow/commit/959f25507fb9db78191c7ed3e540319948e1288c))
|
|
18
|
+
|
|
19
|
+
### 💅 Styles
|
|
20
|
+
|
|
21
|
+
- format package.json with biome (tabs → spaces) ([8560a3f](https://github.com/SylphxAI/flow/commit/8560a3f7c5debf220bf6fd02da19276911e75886))
|
|
22
|
+
|
|
3
23
|
## 3.20.0 (2026-02-08)
|
|
4
24
|
|
|
5
25
|
### ✨ Features
|
package/package.json
CHANGED
|
@@ -308,7 +308,25 @@ export async function executeFlowV2(
|
|
|
308
308
|
continue: options.continue,
|
|
309
309
|
};
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
// Suppress SIGINT in parent — child process handles its own Ctrl+C.
|
|
312
|
+
// Without this, SIGINT propagates to parent and triggers premature cleanup
|
|
313
|
+
// while the child is still running.
|
|
314
|
+
const originalSigintListeners = process.listeners('SIGINT') as ((...args: unknown[]) => void)[];
|
|
315
|
+
process.removeAllListeners('SIGINT');
|
|
316
|
+
process.on('SIGINT', () => {
|
|
317
|
+
// Intentionally empty — child handles Ctrl+C
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await executeTargetCommand(selectedTargetId, systemPrompt, userPrompt, runOptions);
|
|
322
|
+
} finally {
|
|
323
|
+
// Restore original SIGINT listeners after child exits
|
|
324
|
+
process.removeAllListeners('SIGINT');
|
|
325
|
+
for (const listener of originalSigintListeners) {
|
|
326
|
+
process.on('SIGINT', listener);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
312
330
|
await executor.cleanup(projectPath);
|
|
313
331
|
} catch (error) {
|
|
314
332
|
if (error instanceof UserCancelledError) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for CleanupHandler
|
|
3
|
-
* Covers:
|
|
4
|
-
* periodic cleanup gating,
|
|
3
|
+
* Covers: cleanup flow, crash recovery, orphaned project detection,
|
|
4
|
+
* periodic cleanup gating, finalize-after-restore ordering,
|
|
5
|
+
* and legacy migration
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import fs from 'node:fs';
|
|
@@ -16,21 +17,26 @@ function createMockProjectManager(flowHome: string) {
|
|
|
16
17
|
return {
|
|
17
18
|
getFlowHomeDir: () => flowHome,
|
|
18
19
|
getProjectPaths: (hash: string) => ({
|
|
19
|
-
|
|
20
|
+
sessionDir: path.join(flowHome, 'sessions', hash),
|
|
21
|
+
backupRefFile: path.join(flowHome, 'sessions', hash, 'backup.json'),
|
|
22
|
+
pidsDir: path.join(flowHome, 'sessions', hash, 'pids'),
|
|
20
23
|
backupsDir: path.join(flowHome, 'backups', hash),
|
|
21
24
|
secretsDir: path.join(flowHome, 'secrets', hash),
|
|
22
25
|
latestBackup: path.join(flowHome, 'backups', hash, 'latest'),
|
|
23
26
|
}),
|
|
27
|
+
getActiveProjects: vi.fn().mockResolvedValue([]),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
function createMockSessionManager() {
|
|
28
32
|
return {
|
|
29
33
|
detectOrphanedSessions: vi.fn().mockResolvedValue(new Map()),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
releaseSession: vi.fn().mockResolvedValue({ shouldRestore: false, backupRef: null }),
|
|
35
|
+
finalizeSessionCleanup: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
isSessionActive: vi.fn().mockResolvedValue(false),
|
|
33
37
|
cleanupSessionHistory: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
getBackupRef: vi.fn().mockResolvedValue(null),
|
|
39
|
+
acquireSession: vi.fn().mockResolvedValue({ isFirstSession: false, backupRef: null }),
|
|
34
40
|
};
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -38,6 +44,7 @@ function createMockBackupManager() {
|
|
|
38
44
|
return {
|
|
39
45
|
restoreBackup: vi.fn().mockResolvedValue(undefined),
|
|
40
46
|
cleanupOldBackups: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
cleanupOrphanedRestores: vi.fn().mockResolvedValue(undefined),
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -94,24 +101,74 @@ describe('CleanupHandler', () => {
|
|
|
94
101
|
// --- cleanup() ---
|
|
95
102
|
|
|
96
103
|
describe('cleanup()', () => {
|
|
97
|
-
it('should restore backup and
|
|
98
|
-
const
|
|
99
|
-
|
|
104
|
+
it('should restore backup and finalize when last session ends', async () => {
|
|
105
|
+
const backupRef = {
|
|
106
|
+
sessionId: 'session-1',
|
|
107
|
+
projectPath: '/tmp/project',
|
|
108
|
+
backupPath: '/tmp/backup',
|
|
109
|
+
target: 'claude-code',
|
|
110
|
+
};
|
|
111
|
+
mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: true, backupRef });
|
|
100
112
|
|
|
101
113
|
await handler.cleanup('abc123');
|
|
102
114
|
|
|
103
115
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('abc123', 'session-1');
|
|
116
|
+
// CRITICAL: finalize called AFTER restore
|
|
117
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('abc123');
|
|
104
118
|
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('abc123', 3);
|
|
105
119
|
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/project');
|
|
106
120
|
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('abc123');
|
|
107
121
|
});
|
|
108
122
|
|
|
123
|
+
it('should call finalize AFTER restore (ordering)', async () => {
|
|
124
|
+
const callOrder: string[] = [];
|
|
125
|
+
mockSessionManager.releaseSession.mockResolvedValue({
|
|
126
|
+
shouldRestore: true,
|
|
127
|
+
backupRef: {
|
|
128
|
+
sessionId: 's1',
|
|
129
|
+
projectPath: '/tmp/p',
|
|
130
|
+
backupPath: '/tmp/b',
|
|
131
|
+
target: 'claude-code',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
mockBackupManager.restoreBackup.mockImplementation(async () => {
|
|
135
|
+
callOrder.push('restore');
|
|
136
|
+
});
|
|
137
|
+
mockSessionManager.finalizeSessionCleanup.mockImplementation(async () => {
|
|
138
|
+
callOrder.push('finalize');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await handler.cleanup('abc123');
|
|
142
|
+
|
|
143
|
+
expect(callOrder).toEqual(['restore', 'finalize']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should NOT call finalize if restore fails', async () => {
|
|
147
|
+
mockSessionManager.releaseSession.mockResolvedValue({
|
|
148
|
+
shouldRestore: true,
|
|
149
|
+
backupRef: {
|
|
150
|
+
sessionId: 's1',
|
|
151
|
+
projectPath: '/tmp/p',
|
|
152
|
+
backupPath: '/tmp/b',
|
|
153
|
+
target: 'claude-code',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
mockBackupManager.restoreBackup.mockRejectedValue(new Error('restore failed'));
|
|
157
|
+
|
|
158
|
+
// cleanup() will throw since restoreBackup throws
|
|
159
|
+
await expect(handler.cleanup('abc123')).rejects.toThrow('restore failed');
|
|
160
|
+
|
|
161
|
+
// finalize should NOT have been called
|
|
162
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
109
165
|
it('should not restore when other sessions still active', async () => {
|
|
110
|
-
mockSessionManager.
|
|
166
|
+
mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: false, backupRef: null });
|
|
111
167
|
|
|
112
168
|
await handler.cleanup('abc123');
|
|
113
169
|
|
|
114
170
|
expect(mockBackupManager.restoreBackup).not.toHaveBeenCalled();
|
|
171
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
|
|
115
172
|
expect(mockGitStash.popSettingsChanges).not.toHaveBeenCalled();
|
|
116
173
|
expect(mockSecrets.clearSecrets).not.toHaveBeenCalled();
|
|
117
174
|
});
|
|
@@ -120,30 +177,31 @@ describe('CleanupHandler', () => {
|
|
|
120
177
|
// --- recoverOnStartup() ---
|
|
121
178
|
|
|
122
179
|
describe('recoverOnStartup()', () => {
|
|
123
|
-
it('should recover orphaned sessions', async () => {
|
|
124
|
-
const
|
|
180
|
+
it('should recover orphaned sessions with finalize after restore', async () => {
|
|
181
|
+
const backupRef = {
|
|
125
182
|
sessionId: 'session-crashed',
|
|
126
183
|
projectPath: '/tmp/crashed-project',
|
|
127
|
-
|
|
184
|
+
backupPath: '/tmp/backup',
|
|
185
|
+
target: 'claude-code',
|
|
128
186
|
};
|
|
129
|
-
const orphaned = new Map([['hash1',
|
|
187
|
+
const orphaned = new Map([['hash1', backupRef]]);
|
|
130
188
|
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
131
189
|
|
|
132
190
|
await handler.recoverOnStartup();
|
|
133
191
|
|
|
134
192
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('hash1', 'session-crashed');
|
|
135
|
-
expect(mockSessionManager.
|
|
193
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('hash1');
|
|
136
194
|
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/crashed-project');
|
|
137
195
|
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('hash1');
|
|
138
196
|
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('hash1', 3);
|
|
139
197
|
});
|
|
140
198
|
|
|
141
199
|
it('should handle multiple orphaned sessions independently', async () => {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
200
|
+
const ref1 = { sessionId: 's1', projectPath: '/p1', backupPath: '/b1', target: 'claude-code' };
|
|
201
|
+
const ref2 = { sessionId: 's2', projectPath: '/p2', backupPath: '/b2', target: 'claude-code' };
|
|
144
202
|
const orphaned = new Map([
|
|
145
|
-
['h1',
|
|
146
|
-
['h2',
|
|
203
|
+
['h1', ref1],
|
|
204
|
+
['h2', ref2],
|
|
147
205
|
]);
|
|
148
206
|
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
149
207
|
|
|
@@ -156,7 +214,9 @@ describe('CleanupHandler', () => {
|
|
|
156
214
|
|
|
157
215
|
// Second session should still be recovered despite first failure
|
|
158
216
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledTimes(2);
|
|
159
|
-
expect(mockSessionManager.
|
|
217
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('h2');
|
|
218
|
+
// First session's finalize should NOT have been called (restore failed)
|
|
219
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalledWith('h1');
|
|
160
220
|
});
|
|
161
221
|
|
|
162
222
|
it('should skip heavy cleanup when marker is fresh', async () => {
|
|
@@ -209,6 +269,76 @@ describe('CleanupHandler', () => {
|
|
|
209
269
|
});
|
|
210
270
|
});
|
|
211
271
|
|
|
272
|
+
// --- Legacy migration ---
|
|
273
|
+
|
|
274
|
+
describe('migrateLegacySessions (via recoverOnStartup)', () => {
|
|
275
|
+
it('should migrate legacy session files with cleanupRequired=true', async () => {
|
|
276
|
+
// Create a legacy session file
|
|
277
|
+
const legacySession = {
|
|
278
|
+
projectHash: 'abc123',
|
|
279
|
+
projectPath: '/tmp/project',
|
|
280
|
+
sessionId: 'session-legacy',
|
|
281
|
+
pid: 999999,
|
|
282
|
+
startTime: '2026-01-01T00:00:00.000Z',
|
|
283
|
+
backupPath: '/tmp/backups/session-legacy',
|
|
284
|
+
status: 'active',
|
|
285
|
+
target: 'claude-code',
|
|
286
|
+
cleanupRequired: true,
|
|
287
|
+
isOriginal: true,
|
|
288
|
+
sharedBackupId: 'session-legacy',
|
|
289
|
+
refCount: 1,
|
|
290
|
+
activePids: [999999],
|
|
291
|
+
};
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
path.join(flowHome, 'sessions', 'abc123.json'),
|
|
294
|
+
JSON.stringify(legacySession)
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Fresh cleanup marker to skip heavy cleanup
|
|
298
|
+
fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
|
|
299
|
+
|
|
300
|
+
await handler.recoverOnStartup();
|
|
301
|
+
|
|
302
|
+
// Legacy file should be gone
|
|
303
|
+
expect(fs.existsSync(path.join(flowHome, 'sessions', 'abc123.json'))).toBe(false);
|
|
304
|
+
|
|
305
|
+
// New directory structure should exist with backup.json
|
|
306
|
+
const paths = mockProjectManager.getProjectPaths('abc123');
|
|
307
|
+
expect(fs.existsSync(paths.backupRefFile)).toBe(true);
|
|
308
|
+
|
|
309
|
+
const backupRef = JSON.parse(fs.readFileSync(paths.backupRefFile, 'utf-8'));
|
|
310
|
+
expect(backupRef.sessionId).toBe('session-legacy');
|
|
311
|
+
expect(backupRef.projectPath).toBe('/tmp/project');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should migrate legacy session files with cleanupRequired=false (no backup.json)', async () => {
|
|
315
|
+
const legacySession = {
|
|
316
|
+
sessionId: 'session-done',
|
|
317
|
+
backupPath: '/tmp/backup',
|
|
318
|
+
projectPath: '/tmp/project',
|
|
319
|
+
target: 'claude-code',
|
|
320
|
+
cleanupRequired: false,
|
|
321
|
+
pid: 999999,
|
|
322
|
+
startTime: '2026-01-01T00:00:00.000Z',
|
|
323
|
+
};
|
|
324
|
+
fs.writeFileSync(
|
|
325
|
+
path.join(flowHome, 'sessions', 'def456.json'),
|
|
326
|
+
JSON.stringify(legacySession)
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
|
|
330
|
+
|
|
331
|
+
await handler.recoverOnStartup();
|
|
332
|
+
|
|
333
|
+
// Legacy file should be gone
|
|
334
|
+
expect(fs.existsSync(path.join(flowHome, 'sessions', 'def456.json'))).toBe(false);
|
|
335
|
+
|
|
336
|
+
// No backup.json should be created (cleanupRequired was false)
|
|
337
|
+
const paths = mockProjectManager.getProjectPaths('def456');
|
|
338
|
+
expect(fs.existsSync(paths.backupRefFile)).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
212
342
|
// --- Orphaned project cleanup ---
|
|
213
343
|
|
|
214
344
|
describe('cleanupOrphanedProjects (via recoverOnStartup)', () => {
|
|
@@ -248,11 +378,8 @@ describe('CleanupHandler', () => {
|
|
|
248
378
|
);
|
|
249
379
|
|
|
250
380
|
// Mock: this project has an active session
|
|
251
|
-
mockSessionManager.
|
|
252
|
-
|
|
253
|
-
return { projectHash: activeHash };
|
|
254
|
-
}
|
|
255
|
-
return null;
|
|
381
|
+
mockSessionManager.isSessionActive.mockImplementation(async (hash: string) => {
|
|
382
|
+
return hash === activeHash;
|
|
256
383
|
});
|
|
257
384
|
|
|
258
385
|
await handler.recoverOnStartup();
|