ccmanager 3.1.4 → 3.2.1
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/bin/cli.js +85 -0
- package/dist/cli.test.js +9 -10
- package/dist/components/App.test.js +6 -4
- package/dist/components/DeleteWorktree.test.js +22 -10
- package/dist/components/Menu.recent-projects.test.js +22 -0
- package/dist/components/Menu.test.js +5 -3
- package/dist/components/MergeWorktree.test.js +12 -4
- package/dist/components/NewWorktree.test.js +57 -39
- package/dist/components/ProjectList.recent-projects.test.js +5 -3
- package/dist/components/ProjectList.test.js +25 -3
- package/dist/components/Session.js +6 -5
- package/dist/services/bunTerminal.d.ts +53 -0
- package/dist/services/bunTerminal.js +175 -0
- package/dist/services/sessionManager.autoApproval.test.js +73 -41
- package/dist/services/sessionManager.effect.test.js +17 -13
- package/dist/services/sessionManager.js +122 -76
- package/dist/services/sessionManager.statePersistence.test.js +64 -62
- package/dist/services/sessionManager.test.js +44 -23
- package/dist/types/index.d.ts +8 -7
- package/dist/utils/hookExecutor.test.js +55 -54
- package/dist/utils/mutex.d.ts +54 -0
- package/dist/utils/mutex.js +104 -0
- package/dist/utils/worktreeUtils.js +3 -1
- package/dist/utils/worktreeUtils.test.js +5 -4
- package/package.json +39 -29
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from '
|
|
1
|
+
import { spawn } from './bunTerminal.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
4
|
import { exec } from 'child_process';
|
|
@@ -11,6 +11,7 @@ import { Effect } from 'effect';
|
|
|
11
11
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
12
12
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
13
13
|
import { logger } from '../utils/logger.js';
|
|
14
|
+
import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
|
|
14
15
|
const { Terminal } = pkg;
|
|
15
16
|
const execAsync = promisify(exec);
|
|
16
17
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
@@ -29,11 +30,12 @@ export class SessionManager extends EventEmitter {
|
|
|
29
30
|
// Create a detector based on the session's detection strategy
|
|
30
31
|
const strategy = session.detectionStrategy || 'claude';
|
|
31
32
|
const detector = createStateDetector(strategy);
|
|
32
|
-
const
|
|
33
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
34
|
+
const detectedState = detector.detectState(session.terminal, stateData.state);
|
|
33
35
|
// If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
|
|
34
36
|
if (detectedState === 'waiting_input' &&
|
|
35
37
|
configurationManager.isAutoApprovalEnabled() &&
|
|
36
|
-
!
|
|
38
|
+
!stateData.autoApprovalFailed) {
|
|
37
39
|
return 'pending_auto_approval';
|
|
38
40
|
}
|
|
39
41
|
return detectedState;
|
|
@@ -58,80 +60,101 @@ export class SessionManager extends EventEmitter {
|
|
|
58
60
|
// Cancel any existing verification before starting a new one
|
|
59
61
|
this.cancelAutoApprovalVerification(session, 'Restarting verification for pending auto-approval state');
|
|
60
62
|
const abortController = new AbortController();
|
|
61
|
-
session.
|
|
62
|
-
|
|
63
|
+
void session.stateMutex.update(data => ({
|
|
64
|
+
...data,
|
|
65
|
+
autoApprovalAbortController: abortController,
|
|
66
|
+
autoApprovalReason: undefined,
|
|
67
|
+
}));
|
|
63
68
|
// Get terminal content for verification
|
|
64
69
|
const terminalContent = this.getTerminalContent(session);
|
|
65
70
|
// Verify if permission is needed
|
|
66
71
|
void Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission(terminalContent, {
|
|
67
72
|
signal: abortController.signal,
|
|
68
73
|
}))
|
|
69
|
-
.then(autoApprovalResult => {
|
|
74
|
+
.then(async (autoApprovalResult) => {
|
|
70
75
|
if (abortController.signal.aborted) {
|
|
71
76
|
logger.debug(`[${session.id}] Auto-approval verification aborted before completion`);
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
74
79
|
// If state already moved away, skip handling
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const currentState = session.stateMutex.getSnapshot().state;
|
|
81
|
+
if (currentState !== 'pending_auto_approval') {
|
|
82
|
+
logger.debug(`[${session.id}] Skipping auto-approval handling; current state is ${currentState}`);
|
|
77
83
|
return;
|
|
78
84
|
}
|
|
79
85
|
if (autoApprovalResult.needsPermission) {
|
|
80
86
|
// Change state to waiting_input to ask for user permission
|
|
81
87
|
logger.info(`[${session.id}] Auto-approval verification determined user permission needed`);
|
|
82
|
-
session.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
await session.stateMutex.update(data => ({
|
|
89
|
+
...data,
|
|
90
|
+
state: 'waiting_input',
|
|
91
|
+
autoApprovalFailed: true,
|
|
92
|
+
autoApprovalReason: autoApprovalResult.reason,
|
|
93
|
+
pendingState: undefined,
|
|
94
|
+
pendingStateStart: undefined,
|
|
95
|
+
}));
|
|
87
96
|
this.emit('sessionStateChanged', session);
|
|
88
97
|
}
|
|
89
98
|
else {
|
|
90
99
|
// Auto-approve by simulating Enter key press
|
|
91
100
|
logger.info(`[${session.id}] Auto-approval granted, simulating user permission`);
|
|
92
|
-
session.autoApprovalReason = undefined;
|
|
93
101
|
session.process.write('\r');
|
|
94
102
|
// Force state to busy to prevent endless auto-approval
|
|
95
103
|
// when the state detection still sees pending_auto_approval
|
|
96
|
-
session.
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
await session.stateMutex.update(data => ({
|
|
105
|
+
...data,
|
|
106
|
+
state: 'busy',
|
|
107
|
+
autoApprovalReason: undefined,
|
|
108
|
+
pendingState: undefined,
|
|
109
|
+
pendingStateStart: undefined,
|
|
110
|
+
}));
|
|
99
111
|
this.emit('sessionStateChanged', session);
|
|
100
112
|
}
|
|
101
113
|
})
|
|
102
|
-
.catch((error) => {
|
|
114
|
+
.catch(async (error) => {
|
|
103
115
|
if (abortController.signal.aborted) {
|
|
104
116
|
logger.debug(`[${session.id}] Auto-approval verification aborted (${error?.message ?? 'aborted'})`);
|
|
105
117
|
return;
|
|
106
118
|
}
|
|
107
119
|
// On failure, fall back to requiring explicit permission
|
|
108
120
|
logger.error(`[${session.id}] Auto-approval verification failed, requiring user permission`, error);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
session.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
const currentState = session.stateMutex.getSnapshot().state;
|
|
122
|
+
if (currentState === 'pending_auto_approval') {
|
|
123
|
+
await session.stateMutex.update(data => ({
|
|
124
|
+
...data,
|
|
125
|
+
state: 'waiting_input',
|
|
126
|
+
autoApprovalFailed: true,
|
|
127
|
+
autoApprovalReason: error?.message ??
|
|
128
|
+
'Auto-approval verification failed',
|
|
129
|
+
pendingState: undefined,
|
|
130
|
+
pendingStateStart: undefined,
|
|
131
|
+
}));
|
|
117
132
|
this.emit('sessionStateChanged', session);
|
|
118
133
|
}
|
|
119
134
|
})
|
|
120
|
-
.finally(() => {
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
.finally(async () => {
|
|
136
|
+
const currentController = session.stateMutex.getSnapshot().autoApprovalAbortController;
|
|
137
|
+
if (currentController === abortController) {
|
|
138
|
+
await session.stateMutex.update(data => ({
|
|
139
|
+
...data,
|
|
140
|
+
autoApprovalAbortController: undefined,
|
|
141
|
+
}));
|
|
123
142
|
}
|
|
124
143
|
});
|
|
125
144
|
}
|
|
126
145
|
cancelAutoApprovalVerification(session, reason) {
|
|
127
|
-
const
|
|
146
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
147
|
+
const controller = stateData.autoApprovalAbortController;
|
|
128
148
|
if (!controller) {
|
|
129
149
|
return;
|
|
130
150
|
}
|
|
131
151
|
if (!controller.signal.aborted) {
|
|
132
152
|
controller.abort();
|
|
133
153
|
}
|
|
134
|
-
session.
|
|
154
|
+
void session.stateMutex.update(data => ({
|
|
155
|
+
...data,
|
|
156
|
+
autoApprovalAbortController: undefined,
|
|
157
|
+
}));
|
|
135
158
|
logger.info(`[${session.id}] Cancelled auto-approval verification: ${reason}`);
|
|
136
159
|
}
|
|
137
160
|
constructor() {
|
|
@@ -174,7 +197,6 @@ export class SessionManager extends EventEmitter {
|
|
|
174
197
|
id,
|
|
175
198
|
worktreePath,
|
|
176
199
|
process: ptyProcess,
|
|
177
|
-
state: 'busy', // Session starts as busy when created
|
|
178
200
|
output: [],
|
|
179
201
|
outputHistory: [],
|
|
180
202
|
lastActivity: new Date(),
|
|
@@ -185,11 +207,7 @@ export class SessionManager extends EventEmitter {
|
|
|
185
207
|
commandConfig,
|
|
186
208
|
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
187
209
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
188
|
-
|
|
189
|
-
pendingStateStart: undefined,
|
|
190
|
-
autoApprovalFailed: false,
|
|
191
|
-
autoApprovalReason: undefined,
|
|
192
|
-
autoApprovalAbortController: undefined,
|
|
210
|
+
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
193
211
|
};
|
|
194
212
|
// Set up persistent background data handler for state detection
|
|
195
213
|
this.setupBackgroundHandler(session);
|
|
@@ -356,37 +374,47 @@ export class SessionManager extends EventEmitter {
|
|
|
356
374
|
this.setupDataHandler(session);
|
|
357
375
|
// Set up interval-based state detection with persistence
|
|
358
376
|
session.stateCheckInterval = setInterval(() => {
|
|
359
|
-
const
|
|
377
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
378
|
+
const oldState = stateData.state;
|
|
360
379
|
const detectedState = this.detectTerminalState(session);
|
|
361
380
|
const now = Date.now();
|
|
362
381
|
// If detected state is different from current state
|
|
363
382
|
if (detectedState !== oldState) {
|
|
364
383
|
// If this is a new pending state or the pending state changed
|
|
365
|
-
if (
|
|
366
|
-
session.
|
|
367
|
-
|
|
384
|
+
if (stateData.pendingState !== detectedState) {
|
|
385
|
+
void session.stateMutex.update(data => ({
|
|
386
|
+
...data,
|
|
387
|
+
pendingState: detectedState,
|
|
388
|
+
pendingStateStart: now,
|
|
389
|
+
}));
|
|
368
390
|
}
|
|
369
|
-
else if (
|
|
370
|
-
|
|
391
|
+
else if (stateData.pendingState !== undefined &&
|
|
392
|
+
stateData.pendingStateStart !== undefined) {
|
|
371
393
|
// Check if the pending state has persisted long enough
|
|
372
|
-
const duration = now -
|
|
394
|
+
const duration = now - stateData.pendingStateStart;
|
|
373
395
|
if (duration >= STATE_PERSISTENCE_DURATION_MS) {
|
|
374
396
|
// Confirm the state change
|
|
375
|
-
session.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
397
|
+
void session.stateMutex.update(data => {
|
|
398
|
+
const newData = {
|
|
399
|
+
...data,
|
|
400
|
+
state: detectedState,
|
|
401
|
+
pendingState: undefined,
|
|
402
|
+
pendingStateStart: undefined,
|
|
403
|
+
};
|
|
404
|
+
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
405
|
+
// allow future auto-approval attempts.
|
|
406
|
+
if (data.autoApprovalFailed &&
|
|
407
|
+
detectedState !== 'waiting_input' &&
|
|
408
|
+
detectedState !== 'pending_auto_approval') {
|
|
409
|
+
newData.autoApprovalFailed = false;
|
|
410
|
+
newData.autoApprovalReason = undefined;
|
|
411
|
+
}
|
|
412
|
+
return newData;
|
|
413
|
+
});
|
|
414
|
+
if (stateData.autoApprovalAbortController &&
|
|
379
415
|
detectedState !== 'pending_auto_approval') {
|
|
380
416
|
this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
|
|
381
417
|
}
|
|
382
|
-
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
383
|
-
// allow future auto-approval attempts.
|
|
384
|
-
if (session.autoApprovalFailed &&
|
|
385
|
-
detectedState !== 'waiting_input' &&
|
|
386
|
-
detectedState !== 'pending_auto_approval') {
|
|
387
|
-
session.autoApprovalFailed = false;
|
|
388
|
-
session.autoApprovalReason = undefined;
|
|
389
|
-
}
|
|
390
418
|
// Execute status hook asynchronously (non-blocking) using Effect
|
|
391
419
|
void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
|
|
392
420
|
this.emit('sessionStateChanged', session);
|
|
@@ -395,14 +423,18 @@ export class SessionManager extends EventEmitter {
|
|
|
395
423
|
}
|
|
396
424
|
else {
|
|
397
425
|
// Detected state matches current state, clear any pending state
|
|
398
|
-
session.
|
|
399
|
-
|
|
426
|
+
void session.stateMutex.update(data => ({
|
|
427
|
+
...data,
|
|
428
|
+
pendingState: undefined,
|
|
429
|
+
pendingStateStart: undefined,
|
|
430
|
+
}));
|
|
400
431
|
}
|
|
401
432
|
// Handle auto-approval if state is pending_auto_approval and no verification is in progress.
|
|
402
433
|
// This ensures auto-approval is retried when the state remains pending_auto_approval
|
|
403
434
|
// but the previous verification completed (success, failure, timeout, or abort).
|
|
404
|
-
|
|
405
|
-
|
|
435
|
+
const currentStateData = session.stateMutex.getSnapshot();
|
|
436
|
+
if (currentStateData.state === 'pending_auto_approval' &&
|
|
437
|
+
!currentStateData.autoApprovalAbortController) {
|
|
406
438
|
this.handleAutoApproval(session);
|
|
407
439
|
}
|
|
408
440
|
}, STATE_CHECK_INTERVAL_MS);
|
|
@@ -410,7 +442,8 @@ export class SessionManager extends EventEmitter {
|
|
|
410
442
|
this.setupExitHandler(session);
|
|
411
443
|
}
|
|
412
444
|
cleanupSession(session) {
|
|
413
|
-
|
|
445
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
446
|
+
if (stateData.autoApprovalAbortController) {
|
|
414
447
|
this.cancelAutoApprovalVerification(session, 'Session cleanup');
|
|
415
448
|
}
|
|
416
449
|
// Clear the state check interval
|
|
@@ -418,11 +451,13 @@ export class SessionManager extends EventEmitter {
|
|
|
418
451
|
clearInterval(session.stateCheckInterval);
|
|
419
452
|
session.stateCheckInterval = undefined;
|
|
420
453
|
}
|
|
421
|
-
// Clear any pending state
|
|
422
|
-
session.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
454
|
+
// Clear any pending state and update state to idle before destroying
|
|
455
|
+
void session.stateMutex.update(data => ({
|
|
456
|
+
...data,
|
|
457
|
+
state: 'idle',
|
|
458
|
+
pendingState: undefined,
|
|
459
|
+
pendingStateStart: undefined,
|
|
460
|
+
}));
|
|
426
461
|
this.emit('sessionStateChanged', session);
|
|
427
462
|
this.destroySession(session.worktreePath);
|
|
428
463
|
this.emit('sessionExit', session);
|
|
@@ -449,24 +484,34 @@ export class SessionManager extends EventEmitter {
|
|
|
449
484
|
if (!session) {
|
|
450
485
|
return;
|
|
451
486
|
}
|
|
452
|
-
|
|
453
|
-
|
|
487
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
488
|
+
if (stateData.state !== 'pending_auto_approval' &&
|
|
489
|
+
!stateData.autoApprovalAbortController) {
|
|
454
490
|
return;
|
|
455
491
|
}
|
|
456
492
|
this.cancelAutoApprovalVerification(session, reason);
|
|
457
|
-
session.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
493
|
+
void session.stateMutex.update(data => {
|
|
494
|
+
const newData = {
|
|
495
|
+
...data,
|
|
496
|
+
autoApprovalFailed: true,
|
|
497
|
+
autoApprovalReason: reason,
|
|
498
|
+
pendingState: undefined,
|
|
499
|
+
pendingStateStart: undefined,
|
|
500
|
+
};
|
|
501
|
+
if (data.state === 'pending_auto_approval') {
|
|
502
|
+
newData.state = 'waiting_input';
|
|
503
|
+
}
|
|
504
|
+
return newData;
|
|
505
|
+
});
|
|
506
|
+
if (stateData.state === 'pending_auto_approval') {
|
|
463
507
|
this.emit('sessionStateChanged', session);
|
|
464
508
|
}
|
|
465
509
|
}
|
|
466
510
|
destroySession(worktreePath) {
|
|
467
511
|
const session = this.sessions.get(worktreePath);
|
|
468
512
|
if (session) {
|
|
469
|
-
|
|
513
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
514
|
+
if (stateData.autoApprovalAbortController) {
|
|
470
515
|
this.cancelAutoApprovalVerification(session, 'Session destroyed');
|
|
471
516
|
}
|
|
472
517
|
// Clear the state check interval
|
|
@@ -650,7 +695,8 @@ export class SessionManager extends EventEmitter {
|
|
|
650
695
|
total: sessions.length,
|
|
651
696
|
};
|
|
652
697
|
sessions.forEach(session => {
|
|
653
|
-
|
|
698
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
699
|
+
switch (stateData.state) {
|
|
654
700
|
case 'idle':
|
|
655
701
|
counts.idle++;
|
|
656
702
|
break;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { SessionManager } from './sessionManager.js';
|
|
3
|
-
import { spawn } from '
|
|
3
|
+
import { spawn } from './bunTerminal.js';
|
|
4
4
|
import { EventEmitter } from 'events';
|
|
5
5
|
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
6
|
-
vi.mock('
|
|
7
|
-
spawn: vi.fn()
|
|
6
|
+
vi.mock('./bunTerminal.js', () => ({
|
|
7
|
+
spawn: vi.fn(function () {
|
|
8
|
+
return null;
|
|
9
|
+
}),
|
|
8
10
|
}));
|
|
9
11
|
vi.mock('./configurationManager.js', () => ({
|
|
10
12
|
configurationManager: {
|
|
@@ -83,15 +85,15 @@ describe('SessionManager - State Persistence', () => {
|
|
|
83
85
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
84
86
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
85
87
|
// Initial state should be busy
|
|
86
|
-
expect(session.state).toBe('busy');
|
|
88
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
87
89
|
// Simulate output that would trigger idle state
|
|
88
90
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
89
|
-
// Advance time less than persistence duration
|
|
90
|
-
vi.
|
|
91
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
92
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
91
93
|
// State should still be busy, but pending state should be set
|
|
92
|
-
expect(session.state).toBe('busy');
|
|
93
|
-
expect(session.pendingState).toBe('idle');
|
|
94
|
-
expect(session.pendingStateStart).toBeDefined();
|
|
94
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
95
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
96
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
95
97
|
});
|
|
96
98
|
it('should change state after persistence duration is met', async () => {
|
|
97
99
|
const { Effect } = await import('effect');
|
|
@@ -100,19 +102,19 @@ describe('SessionManager - State Persistence', () => {
|
|
|
100
102
|
const stateChangeHandler = vi.fn();
|
|
101
103
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
102
104
|
// Initial state should be busy
|
|
103
|
-
expect(session.state).toBe('busy');
|
|
105
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
104
106
|
// Simulate output that would trigger idle state
|
|
105
107
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
106
|
-
// Advance time less than persistence duration
|
|
107
|
-
vi.
|
|
108
|
-
expect(session.state).toBe('busy');
|
|
108
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
109
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
110
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
109
111
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
110
112
|
// Advance time to exceed persistence duration
|
|
111
|
-
vi.
|
|
113
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
112
114
|
// State should now be changed
|
|
113
|
-
expect(session.state).toBe('idle');
|
|
114
|
-
expect(session.pendingState).toBeUndefined();
|
|
115
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
115
|
+
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
116
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
117
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
116
118
|
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
117
119
|
});
|
|
118
120
|
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
@@ -120,40 +122,40 @@ describe('SessionManager - State Persistence', () => {
|
|
|
120
122
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
121
123
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
122
124
|
// Initial state should be busy
|
|
123
|
-
expect(session.state).toBe('busy');
|
|
125
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
124
126
|
// Simulate output that would trigger idle state
|
|
125
127
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
126
|
-
// Advance time less than persistence duration
|
|
127
|
-
vi.
|
|
128
|
-
expect(session.pendingState).toBe('idle');
|
|
128
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
129
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
130
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
129
131
|
// Simulate output that would trigger waiting_input state
|
|
130
132
|
eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
131
|
-
// Advance time to trigger another check
|
|
132
|
-
vi.
|
|
133
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
134
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
133
135
|
// Pending state should now be waiting_input, not idle
|
|
134
|
-
expect(session.state).toBe('busy'); // Still original state
|
|
135
|
-
expect(session.pendingState).toBe('waiting_input');
|
|
136
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
137
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
136
138
|
});
|
|
137
139
|
it('should clear pending state if detected state returns to current state', async () => {
|
|
138
140
|
const { Effect } = await import('effect');
|
|
139
141
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
140
142
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
141
143
|
// Initial state should be busy
|
|
142
|
-
expect(session.state).toBe('busy');
|
|
144
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
143
145
|
// Simulate output that would trigger idle state
|
|
144
146
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
145
|
-
// Advance time less than persistence duration
|
|
146
|
-
vi.
|
|
147
|
-
expect(session.pendingState).toBe('idle');
|
|
148
|
-
expect(session.pendingStateStart).toBeDefined();
|
|
147
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
148
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
149
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
150
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
149
151
|
// Simulate output that would trigger busy state again (back to original)
|
|
150
152
|
eventEmitter.emit('data', 'ESC to interrupt');
|
|
151
|
-
// Advance time to trigger another check
|
|
152
|
-
vi.
|
|
153
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
154
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
153
155
|
// Pending state should be cleared
|
|
154
|
-
expect(session.state).toBe('busy');
|
|
155
|
-
expect(session.pendingState).toBeUndefined();
|
|
156
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
156
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
157
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
158
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
157
159
|
});
|
|
158
160
|
it('should not confirm state changes that do not persist long enough', async () => {
|
|
159
161
|
const { Effect } = await import('effect');
|
|
@@ -162,22 +164,22 @@ describe('SessionManager - State Persistence', () => {
|
|
|
162
164
|
const stateChangeHandler = vi.fn();
|
|
163
165
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
164
166
|
// Initial state should be busy
|
|
165
|
-
expect(session.state).toBe('busy');
|
|
167
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
166
168
|
// Try to change to idle
|
|
167
169
|
eventEmitter.emit('data', 'Some idle output\n');
|
|
168
|
-
// Wait for detection but not full persistence (less than 200ms)
|
|
169
|
-
vi.
|
|
170
|
+
// Wait for detection but not full persistence (less than 200ms) (use async to process mutex updates)
|
|
171
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
170
172
|
// Should have pending state but not confirmed
|
|
171
|
-
expect(session.state).toBe('busy');
|
|
172
|
-
expect(session.pendingState).toBe('idle');
|
|
173
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
174
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
173
175
|
// Now change to a different state before idle persists
|
|
174
176
|
// Clear terminal first and add waiting prompt
|
|
175
177
|
eventEmitter.emit('data', '\x1b[2J\x1b[HDo you want to continue?\n❯ 1. Yes');
|
|
176
|
-
// Advance time to detect new state but still less than persistence duration from first change
|
|
177
|
-
vi.
|
|
178
|
+
// Advance time to detect new state but still less than persistence duration from first change (use async to process mutex updates)
|
|
179
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
|
|
178
180
|
// Pending state should have changed to waiting_input
|
|
179
|
-
expect(session.state).toBe('busy'); // Still original state
|
|
180
|
-
expect(session.pendingState).toBe('waiting_input');
|
|
181
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
182
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
181
183
|
// Since states kept changing before persisting, no state change should have been confirmed
|
|
182
184
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
183
185
|
});
|
|
@@ -187,10 +189,10 @@ describe('SessionManager - State Persistence', () => {
|
|
|
187
189
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
188
190
|
// Simulate output that would trigger idle state
|
|
189
191
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
190
|
-
// Advance time less than persistence duration
|
|
191
|
-
vi.
|
|
192
|
-
expect(session.pendingState).toBe('idle');
|
|
193
|
-
expect(session.pendingStateStart).toBeDefined();
|
|
192
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
193
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
194
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
195
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
194
196
|
// Destroy the session
|
|
195
197
|
sessionManager.destroySession('/test/path');
|
|
196
198
|
// Check that pending state is cleared
|
|
@@ -204,26 +206,26 @@ describe('SessionManager - State Persistence', () => {
|
|
|
204
206
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
205
207
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
206
208
|
// Both should start as busy
|
|
207
|
-
expect(session1.state).toBe('busy');
|
|
208
|
-
expect(session2.state).toBe('busy');
|
|
209
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
210
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
209
211
|
// Simulate different outputs for each session
|
|
210
212
|
// Session 1 goes to idle
|
|
211
213
|
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
212
214
|
// Session 2 goes to waiting_input
|
|
213
215
|
eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
214
|
-
// Advance time to check but not confirm
|
|
215
|
-
vi.
|
|
216
|
+
// Advance time to check but not confirm (use async to process mutex updates)
|
|
217
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
216
218
|
// Both should have pending states but not changed yet
|
|
217
|
-
expect(session1.state).toBe('busy');
|
|
218
|
-
expect(session1.pendingState).toBe('idle');
|
|
219
|
-
expect(session2.state).toBe('busy');
|
|
220
|
-
expect(session2.pendingState).toBe('waiting_input');
|
|
221
|
-
// Advance time to confirm both
|
|
222
|
-
vi.
|
|
219
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
220
|
+
expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
221
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
222
|
+
expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
223
|
+
// Advance time to confirm both (use async to process mutex updates)
|
|
224
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
223
225
|
// Both should now be in their new states
|
|
224
|
-
expect(session1.state).toBe('idle');
|
|
225
|
-
expect(session1.pendingState).toBeUndefined();
|
|
226
|
-
expect(session2.state).toBe('waiting_input');
|
|
227
|
-
expect(session2.pendingState).toBeUndefined();
|
|
226
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
227
|
+
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
228
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
229
|
+
expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
228
230
|
});
|
|
229
231
|
});
|