ccmanager 3.1.4 → 3.1.5
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/dist/components/Session.js +6 -5
- package/dist/services/sessionManager.autoApproval.test.js +58 -30
- package/dist/services/sessionManager.effect.test.js +1 -1
- package/dist/services/sessionManager.js +121 -75
- package/dist/services/sessionManager.statePersistence.test.js +59 -59
- package/dist/services/sessionManager.test.js +15 -8
- package/dist/types/index.d.ts +7 -6
- package/dist/utils/hookExecutor.test.js +5 -16
- 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 +1 -1
|
@@ -5,17 +5,18 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
5
5
|
const { stdout } = useStdout();
|
|
6
6
|
const [isExiting, setIsExiting] = useState(false);
|
|
7
7
|
const deriveStatus = (currentSession) => {
|
|
8
|
+
const stateData = currentSession.stateMutex.getSnapshot();
|
|
8
9
|
// Always prioritize showing the manual approval notice when verification failed
|
|
9
|
-
if (
|
|
10
|
-
const reason =
|
|
11
|
-
? ` Reason: ${
|
|
10
|
+
if (stateData.autoApprovalFailed) {
|
|
11
|
+
const reason = stateData.autoApprovalReason
|
|
12
|
+
? ` Reason: ${stateData.autoApprovalReason}.`
|
|
12
13
|
: '';
|
|
13
14
|
return {
|
|
14
15
|
message: `Auto-approval failed.${reason} Manual approval required—respond to the prompt.`,
|
|
15
16
|
variant: 'error',
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
|
-
if (
|
|
19
|
+
if (stateData.state === 'pending_auto_approval') {
|
|
19
20
|
return {
|
|
20
21
|
message: 'Auto-approval pending... verifying permissions (press any key to cancel)',
|
|
21
22
|
variant: 'pending',
|
|
@@ -192,7 +193,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
192
193
|
onReturnToMenu();
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
195
|
-
if (session.state === 'pending_auto_approval') {
|
|
196
|
+
if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
|
|
196
197
|
sessionManager.cancelAutoApproval(session.worktreePath, 'User input received during auto-approval');
|
|
197
198
|
}
|
|
198
199
|
// Pass all other input directly to the PTY
|
|
@@ -4,7 +4,11 @@ import { spawn } from 'node-pty';
|
|
|
4
4
|
import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
5
|
import { Effect } from 'effect';
|
|
6
6
|
const detectStateMock = vi.fn();
|
|
7
|
-
|
|
7
|
+
// Create a deferred promise pattern for controllable mock
|
|
8
|
+
let verifyResolve = null;
|
|
9
|
+
const verifyNeedsPermissionMock = vi.fn(() => Effect.promise(() => new Promise(resolve => {
|
|
10
|
+
verifyResolve = resolve;
|
|
11
|
+
})));
|
|
8
12
|
vi.mock('node-pty', () => ({
|
|
9
13
|
spawn: vi.fn(),
|
|
10
14
|
}));
|
|
@@ -70,6 +74,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
70
74
|
vi.useFakeTimers();
|
|
71
75
|
detectStateMock.mockReset();
|
|
72
76
|
verifyNeedsPermissionMock.mockClear();
|
|
77
|
+
verifyResolve = null;
|
|
73
78
|
mockPtyInstances = new Map();
|
|
74
79
|
eventEmitters = new Map();
|
|
75
80
|
spawn.mockImplementation((_command, _args, options) => {
|
|
@@ -124,36 +129,52 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
124
129
|
it('re-enables auto approval after leaving waiting_input', async () => {
|
|
125
130
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
126
131
|
// Simulate a prior auto-approval failure
|
|
127
|
-
session.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(session.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expect(session.
|
|
139
|
-
|
|
132
|
+
await session.stateMutex.update(data => ({
|
|
133
|
+
...data,
|
|
134
|
+
autoApprovalFailed: true,
|
|
135
|
+
}));
|
|
136
|
+
// First waiting_input cycle (auto-approval suppressed) (use async to process mutex updates)
|
|
137
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
|
|
138
|
+
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
139
|
+
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
140
|
+
// Transition back to busy should reset the failure flag (use async to process mutex updates)
|
|
141
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
|
|
142
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
143
|
+
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
144
|
+
// Next waiting_input should trigger pending_auto_approval (use async to process mutex updates)
|
|
145
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
|
|
146
|
+
// State should now be pending_auto_approval (waiting for verification)
|
|
147
|
+
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
140
148
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
149
|
+
// Resolve the verification (needsPermission: false means auto-approve)
|
|
150
|
+
expect(verifyResolve).not.toBeNull();
|
|
151
|
+
verifyResolve({ needsPermission: false });
|
|
152
|
+
await Promise.resolve(); // allow handleAutoApproval promise to resolve
|
|
141
153
|
});
|
|
142
154
|
it('cancels auto approval when user input is detected', async () => {
|
|
143
155
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
144
156
|
const abortController = new AbortController();
|
|
145
|
-
session.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
await session.stateMutex.update(data => ({
|
|
158
|
+
...data,
|
|
159
|
+
state: 'pending_auto_approval',
|
|
160
|
+
autoApprovalAbortController: abortController,
|
|
161
|
+
pendingState: 'pending_auto_approval',
|
|
162
|
+
pendingStateStart: Date.now(),
|
|
163
|
+
}));
|
|
149
164
|
const handler = vi.fn();
|
|
150
165
|
sessionManager.on('sessionStateChanged', handler);
|
|
151
166
|
sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
|
|
167
|
+
// Wait for async mutex update to complete (use vi.waitFor for proper async handling)
|
|
168
|
+
await vi.waitFor(() => {
|
|
169
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
170
|
+
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
152
173
|
expect(abortController.signal.aborted).toBe(true);
|
|
153
|
-
expect(
|
|
154
|
-
expect(
|
|
155
|
-
expect(
|
|
156
|
-
expect(
|
|
174
|
+
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
175
|
+
expect(stateData.autoApprovalFailed).toBe(true);
|
|
176
|
+
expect(stateData.state).toBe('waiting_input');
|
|
177
|
+
expect(stateData.pendingState).toBeUndefined();
|
|
157
178
|
expect(handler).toHaveBeenCalledWith(session);
|
|
158
179
|
sessionManager.off('sessionStateChanged', handler);
|
|
159
180
|
});
|
|
@@ -163,19 +184,26 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
163
184
|
expect(mockPty).toBeDefined();
|
|
164
185
|
const handler = vi.fn();
|
|
165
186
|
sessionManager.on('sessionStateChanged', handler);
|
|
166
|
-
// Advance to pending_auto_approval state
|
|
167
|
-
vi.
|
|
168
|
-
|
|
187
|
+
// Advance to pending_auto_approval state (use async to process mutex updates)
|
|
188
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
|
|
189
|
+
// State should be pending_auto_approval (waiting for verification)
|
|
190
|
+
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
191
|
+
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
192
|
+
// Resolve the verification (needsPermission: false means auto-approve)
|
|
193
|
+
expect(verifyResolve).not.toBeNull();
|
|
194
|
+
verifyResolve({ needsPermission: false });
|
|
169
195
|
// Wait for handleAutoApproval promise chain to fully resolve
|
|
170
196
|
await vi.waitFor(() => {
|
|
171
|
-
expect(session.state).toBe('busy');
|
|
197
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
172
198
|
});
|
|
173
|
-
expect(session.pendingState).toBeUndefined();
|
|
174
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
199
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
200
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
175
201
|
// Verify Enter key was sent to approve
|
|
176
202
|
expect(mockPty.write).toHaveBeenCalledWith('\r');
|
|
177
|
-
// Verify sessionStateChanged was emitted
|
|
178
|
-
|
|
203
|
+
// Verify sessionStateChanged was emitted with session containing state=busy
|
|
204
|
+
const lastCall = handler.mock.calls[handler.mock.calls.length - 1];
|
|
205
|
+
expect(lastCall).toBeDefined();
|
|
206
|
+
expect(lastCall[0].stateMutex.getSnapshot().state).toBe('busy');
|
|
179
207
|
sessionManager.off('sessionStateChanged', handler);
|
|
180
208
|
});
|
|
181
209
|
});
|
|
@@ -110,7 +110,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
110
110
|
const session = await Effect.runPromise(effect);
|
|
111
111
|
expect(session).toBeDefined();
|
|
112
112
|
expect(session.worktreePath).toBe('/test/worktree');
|
|
113
|
-
expect(session.state).toBe('busy');
|
|
113
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
114
114
|
});
|
|
115
115
|
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
116
116
|
// Setup mocks - both return null/undefined
|
|
@@ -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;
|
|
@@ -83,15 +83,15 @@ describe('SessionManager - State Persistence', () => {
|
|
|
83
83
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
84
84
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
85
85
|
// Initial state should be busy
|
|
86
|
-
expect(session.state).toBe('busy');
|
|
86
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
87
87
|
// Simulate output that would trigger idle state
|
|
88
88
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
89
|
-
// Advance time less than persistence duration
|
|
90
|
-
vi.
|
|
89
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
90
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
91
91
|
// 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();
|
|
92
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
93
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
94
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
95
95
|
});
|
|
96
96
|
it('should change state after persistence duration is met', async () => {
|
|
97
97
|
const { Effect } = await import('effect');
|
|
@@ -100,19 +100,19 @@ describe('SessionManager - State Persistence', () => {
|
|
|
100
100
|
const stateChangeHandler = vi.fn();
|
|
101
101
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
102
102
|
// Initial state should be busy
|
|
103
|
-
expect(session.state).toBe('busy');
|
|
103
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
104
104
|
// Simulate output that would trigger idle state
|
|
105
105
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
106
|
-
// Advance time less than persistence duration
|
|
107
|
-
vi.
|
|
108
|
-
expect(session.state).toBe('busy');
|
|
106
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
107
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
108
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
109
109
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
110
110
|
// Advance time to exceed persistence duration
|
|
111
|
-
vi.
|
|
111
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
112
112
|
// State should now be changed
|
|
113
|
-
expect(session.state).toBe('idle');
|
|
114
|
-
expect(session.pendingState).toBeUndefined();
|
|
115
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
113
|
+
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
114
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
115
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
116
116
|
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
117
117
|
});
|
|
118
118
|
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
@@ -120,40 +120,40 @@ describe('SessionManager - State Persistence', () => {
|
|
|
120
120
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
121
121
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
122
122
|
// Initial state should be busy
|
|
123
|
-
expect(session.state).toBe('busy');
|
|
123
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
124
124
|
// Simulate output that would trigger idle state
|
|
125
125
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
126
|
-
// Advance time less than persistence duration
|
|
127
|
-
vi.
|
|
128
|
-
expect(session.pendingState).toBe('idle');
|
|
126
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
127
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
128
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
129
129
|
// Simulate output that would trigger waiting_input state
|
|
130
130
|
eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
131
|
-
// Advance time to trigger another check
|
|
132
|
-
vi.
|
|
131
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
132
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
133
133
|
// 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');
|
|
134
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
135
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
136
136
|
});
|
|
137
137
|
it('should clear pending state if detected state returns to current state', async () => {
|
|
138
138
|
const { Effect } = await import('effect');
|
|
139
139
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
140
140
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
141
141
|
// Initial state should be busy
|
|
142
|
-
expect(session.state).toBe('busy');
|
|
142
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
143
143
|
// Simulate output that would trigger idle state
|
|
144
144
|
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();
|
|
145
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
146
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
147
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
148
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
149
149
|
// Simulate output that would trigger busy state again (back to original)
|
|
150
150
|
eventEmitter.emit('data', 'ESC to interrupt');
|
|
151
|
-
// Advance time to trigger another check
|
|
152
|
-
vi.
|
|
151
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
152
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
153
153
|
// Pending state should be cleared
|
|
154
|
-
expect(session.state).toBe('busy');
|
|
155
|
-
expect(session.pendingState).toBeUndefined();
|
|
156
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
154
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
155
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
156
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
157
157
|
});
|
|
158
158
|
it('should not confirm state changes that do not persist long enough', async () => {
|
|
159
159
|
const { Effect } = await import('effect');
|
|
@@ -162,22 +162,22 @@ describe('SessionManager - State Persistence', () => {
|
|
|
162
162
|
const stateChangeHandler = vi.fn();
|
|
163
163
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
164
164
|
// Initial state should be busy
|
|
165
|
-
expect(session.state).toBe('busy');
|
|
165
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
166
166
|
// Try to change to idle
|
|
167
167
|
eventEmitter.emit('data', 'Some idle output\n');
|
|
168
|
-
// Wait for detection but not full persistence (less than 200ms)
|
|
169
|
-
vi.
|
|
168
|
+
// Wait for detection but not full persistence (less than 200ms) (use async to process mutex updates)
|
|
169
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
170
170
|
// Should have pending state but not confirmed
|
|
171
|
-
expect(session.state).toBe('busy');
|
|
172
|
-
expect(session.pendingState).toBe('idle');
|
|
171
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
172
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
173
173
|
// Now change to a different state before idle persists
|
|
174
174
|
// Clear terminal first and add waiting prompt
|
|
175
175
|
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.
|
|
176
|
+
// Advance time to detect new state but still less than persistence duration from first change (use async to process mutex updates)
|
|
177
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
|
|
178
178
|
// Pending state should have changed to waiting_input
|
|
179
|
-
expect(session.state).toBe('busy'); // Still original state
|
|
180
|
-
expect(session.pendingState).toBe('waiting_input');
|
|
179
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
180
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
181
181
|
// Since states kept changing before persisting, no state change should have been confirmed
|
|
182
182
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
183
183
|
});
|
|
@@ -187,10 +187,10 @@ describe('SessionManager - State Persistence', () => {
|
|
|
187
187
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
188
188
|
// Simulate output that would trigger idle state
|
|
189
189
|
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();
|
|
190
|
+
// Advance time less than persistence duration (use async to process mutex updates)
|
|
191
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
192
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
193
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
194
194
|
// Destroy the session
|
|
195
195
|
sessionManager.destroySession('/test/path');
|
|
196
196
|
// Check that pending state is cleared
|
|
@@ -204,26 +204,26 @@ describe('SessionManager - State Persistence', () => {
|
|
|
204
204
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
205
205
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
206
206
|
// Both should start as busy
|
|
207
|
-
expect(session1.state).toBe('busy');
|
|
208
|
-
expect(session2.state).toBe('busy');
|
|
207
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
208
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
209
209
|
// Simulate different outputs for each session
|
|
210
210
|
// Session 1 goes to idle
|
|
211
211
|
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
212
212
|
// Session 2 goes to waiting_input
|
|
213
213
|
eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
214
|
-
// Advance time to check but not confirm
|
|
215
|
-
vi.
|
|
214
|
+
// Advance time to check but not confirm (use async to process mutex updates)
|
|
215
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
|
|
216
216
|
// 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.
|
|
217
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
218
|
+
expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
219
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
220
|
+
expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
221
|
+
// Advance time to confirm both (use async to process mutex updates)
|
|
222
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
223
223
|
// 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();
|
|
224
|
+
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
225
|
+
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
226
|
+
expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
227
|
+
expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
228
228
|
});
|
|
229
229
|
});
|
|
@@ -705,13 +705,20 @@ describe('SessionManager', () => {
|
|
|
705
705
|
});
|
|
706
706
|
describe('static methods', () => {
|
|
707
707
|
describe('getSessionCounts', () => {
|
|
708
|
+
// Helper to create mock session with stateMutex
|
|
709
|
+
const createMockSession = (id, state) => ({
|
|
710
|
+
id,
|
|
711
|
+
stateMutex: {
|
|
712
|
+
getSnapshot: () => ({ state }),
|
|
713
|
+
},
|
|
714
|
+
});
|
|
708
715
|
it('should count sessions by state', () => {
|
|
709
716
|
const sessions = [
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
717
|
+
createMockSession('1', 'idle'),
|
|
718
|
+
createMockSession('2', 'busy'),
|
|
719
|
+
createMockSession('3', 'busy'),
|
|
720
|
+
createMockSession('4', 'waiting_input'),
|
|
721
|
+
createMockSession('5', 'idle'),
|
|
715
722
|
];
|
|
716
723
|
const counts = SessionManager.getSessionCounts(sessions);
|
|
717
724
|
expect(counts.idle).toBe(2);
|
|
@@ -728,9 +735,9 @@ describe('SessionManager', () => {
|
|
|
728
735
|
});
|
|
729
736
|
it('should handle sessions with single state', () => {
|
|
730
737
|
const sessions = [
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
738
|
+
createMockSession('1', 'busy'),
|
|
739
|
+
createMockSession('2', 'busy'),
|
|
740
|
+
createMockSession('3', 'busy'),
|
|
734
741
|
];
|
|
735
742
|
const counts = SessionManager.getSessionCounts(sessions);
|
|
736
743
|
expect(counts.idle).toBe(0);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IPty } from 'node-pty';
|
|
2
2
|
import type pkg from '@xterm/headless';
|
|
3
3
|
import { GitStatus } from '../utils/gitStatus.js';
|
|
4
|
+
import { Mutex, SessionStateData } from '../utils/mutex.js';
|
|
4
5
|
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
5
6
|
export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
|
|
6
7
|
export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline';
|
|
@@ -16,7 +17,6 @@ export interface Session {
|
|
|
16
17
|
id: string;
|
|
17
18
|
worktreePath: string;
|
|
18
19
|
process: IPty;
|
|
19
|
-
state: SessionState;
|
|
20
20
|
output: string[];
|
|
21
21
|
outputHistory: Buffer[];
|
|
22
22
|
lastActivity: Date;
|
|
@@ -27,11 +27,12 @@ export interface Session {
|
|
|
27
27
|
commandConfig: CommandConfig | undefined;
|
|
28
28
|
detectionStrategy: StateDetectionStrategy | undefined;
|
|
29
29
|
devcontainerConfig: DevcontainerConfig | undefined;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Mutex-protected session state data.
|
|
32
|
+
* Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
|
|
33
|
+
* Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
|
|
34
|
+
*/
|
|
35
|
+
stateMutex: Mutex<SessionStateData>;
|
|
35
36
|
}
|
|
36
37
|
export interface AutoApprovalResponse {
|
|
37
38
|
needsPermission: boolean;
|
|
@@ -7,6 +7,7 @@ import { join } from 'path';
|
|
|
7
7
|
import { configurationManager } from '../services/configurationManager.js';
|
|
8
8
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
9
9
|
import { GitError } from '../types/errors.js';
|
|
10
|
+
import { Mutex, createInitialSessionStateData } from './mutex.js';
|
|
10
11
|
// Mock the configurationManager
|
|
11
12
|
vi.mock('../services/configurationManager.js', () => ({
|
|
12
13
|
configurationManager: {
|
|
@@ -262,17 +263,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
262
263
|
terminal: {},
|
|
263
264
|
output: [],
|
|
264
265
|
outputHistory: [],
|
|
265
|
-
state: 'idle',
|
|
266
266
|
stateCheckInterval: undefined,
|
|
267
267
|
isPrimaryCommand: true,
|
|
268
268
|
commandConfig: undefined,
|
|
269
269
|
detectionStrategy: 'claude',
|
|
270
270
|
devcontainerConfig: undefined,
|
|
271
|
-
pendingState: undefined,
|
|
272
|
-
pendingStateStart: undefined,
|
|
273
271
|
lastActivity: new Date(),
|
|
274
272
|
isActive: true,
|
|
275
|
-
|
|
273
|
+
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
276
274
|
};
|
|
277
275
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
278
276
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
@@ -317,17 +315,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
317
315
|
terminal: {},
|
|
318
316
|
output: [],
|
|
319
317
|
outputHistory: [],
|
|
320
|
-
state: 'idle',
|
|
321
318
|
stateCheckInterval: undefined,
|
|
322
319
|
isPrimaryCommand: true,
|
|
323
320
|
commandConfig: undefined,
|
|
324
321
|
detectionStrategy: 'claude',
|
|
325
322
|
devcontainerConfig: undefined,
|
|
326
|
-
pendingState: undefined,
|
|
327
|
-
pendingStateStart: undefined,
|
|
328
323
|
lastActivity: new Date(),
|
|
329
324
|
isActive: true,
|
|
330
|
-
|
|
325
|
+
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
331
326
|
};
|
|
332
327
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
333
328
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
@@ -370,17 +365,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
370
365
|
terminal: {},
|
|
371
366
|
output: [],
|
|
372
367
|
outputHistory: [],
|
|
373
|
-
state: 'idle',
|
|
374
368
|
stateCheckInterval: undefined,
|
|
375
369
|
isPrimaryCommand: true,
|
|
376
370
|
commandConfig: undefined,
|
|
377
371
|
detectionStrategy: 'claude',
|
|
378
372
|
devcontainerConfig: undefined,
|
|
379
|
-
pendingState: undefined,
|
|
380
|
-
pendingStateStart: undefined,
|
|
381
373
|
lastActivity: new Date(),
|
|
382
374
|
isActive: true,
|
|
383
|
-
|
|
375
|
+
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
384
376
|
};
|
|
385
377
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
386
378
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
@@ -425,17 +417,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
425
417
|
terminal: {},
|
|
426
418
|
output: [],
|
|
427
419
|
outputHistory: [],
|
|
428
|
-
state: 'idle',
|
|
429
420
|
stateCheckInterval: undefined,
|
|
430
421
|
isPrimaryCommand: true,
|
|
431
422
|
commandConfig: undefined,
|
|
432
423
|
detectionStrategy: 'claude',
|
|
433
424
|
devcontainerConfig: undefined,
|
|
434
|
-
pendingState: undefined,
|
|
435
|
-
pendingStateStart: undefined,
|
|
436
425
|
lastActivity: new Date(),
|
|
437
426
|
isActive: true,
|
|
438
|
-
|
|
427
|
+
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
439
428
|
};
|
|
440
429
|
// Mock WorktreeService to fail with GitError
|
|
441
430
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A simple mutex implementation for protecting shared state.
|
|
3
|
+
* Provides exclusive access to wrapped data through async locking.
|
|
4
|
+
*/
|
|
5
|
+
export declare class Mutex<T> {
|
|
6
|
+
private data;
|
|
7
|
+
private locked;
|
|
8
|
+
private waitQueue;
|
|
9
|
+
constructor(initialData: T);
|
|
10
|
+
/**
|
|
11
|
+
* Acquire the lock. Returns a promise that resolves when the lock is acquired.
|
|
12
|
+
*/
|
|
13
|
+
private acquire;
|
|
14
|
+
/**
|
|
15
|
+
* Release the lock, allowing the next waiter to proceed.
|
|
16
|
+
*/
|
|
17
|
+
private release;
|
|
18
|
+
/**
|
|
19
|
+
* Run a function with exclusive access to the protected data.
|
|
20
|
+
* The lock is acquired before the function runs and released after it completes.
|
|
21
|
+
*
|
|
22
|
+
* @param fn - Function that receives the current data and returns updated data or a promise of updated data
|
|
23
|
+
* @returns Promise that resolves with the function's return value
|
|
24
|
+
*/
|
|
25
|
+
runExclusive<R>(fn: (data: T) => R | Promise<R>): Promise<R>;
|
|
26
|
+
/**
|
|
27
|
+
* Run a function with exclusive access and update the protected data.
|
|
28
|
+
* The lock is acquired before the function runs and released after it completes.
|
|
29
|
+
*
|
|
30
|
+
* @param fn - Function that receives the current data and returns the updated data
|
|
31
|
+
*/
|
|
32
|
+
update(fn: (data: T) => T | Promise<T>): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Get a snapshot of the current data without acquiring the lock.
|
|
35
|
+
* Use with caution - this does not guarantee consistency.
|
|
36
|
+
* Prefer runExclusive for reads that need to be consistent with writes.
|
|
37
|
+
*/
|
|
38
|
+
getSnapshot(): T;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Interface for the session state data protected by mutex.
|
|
42
|
+
*/
|
|
43
|
+
export interface SessionStateData {
|
|
44
|
+
state: import('../types/index.js').SessionState;
|
|
45
|
+
pendingState: import('../types/index.js').SessionState | undefined;
|
|
46
|
+
pendingStateStart: number | undefined;
|
|
47
|
+
autoApprovalFailed: boolean;
|
|
48
|
+
autoApprovalReason: string | undefined;
|
|
49
|
+
autoApprovalAbortController: AbortController | undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create initial session state data with default values.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createInitialSessionStateData(): SessionStateData;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A simple mutex implementation for protecting shared state.
|
|
3
|
+
* Provides exclusive access to wrapped data through async locking.
|
|
4
|
+
*/
|
|
5
|
+
export class Mutex {
|
|
6
|
+
constructor(initialData) {
|
|
7
|
+
Object.defineProperty(this, "data", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value: void 0
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(this, "locked", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: false
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(this, "waitQueue", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: []
|
|
24
|
+
});
|
|
25
|
+
this.data = initialData;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Acquire the lock. Returns a promise that resolves when the lock is acquired.
|
|
29
|
+
*/
|
|
30
|
+
async acquire() {
|
|
31
|
+
if (!this.locked) {
|
|
32
|
+
this.locked = true;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
return new Promise(resolve => {
|
|
36
|
+
this.waitQueue.push(resolve);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Release the lock, allowing the next waiter to proceed.
|
|
41
|
+
*/
|
|
42
|
+
release() {
|
|
43
|
+
const next = this.waitQueue.shift();
|
|
44
|
+
if (next) {
|
|
45
|
+
next();
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
this.locked = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run a function with exclusive access to the protected data.
|
|
53
|
+
* The lock is acquired before the function runs and released after it completes.
|
|
54
|
+
*
|
|
55
|
+
* @param fn - Function that receives the current data and returns updated data or a promise of updated data
|
|
56
|
+
* @returns Promise that resolves with the function's return value
|
|
57
|
+
*/
|
|
58
|
+
async runExclusive(fn) {
|
|
59
|
+
await this.acquire();
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn(this.data);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.release();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run a function with exclusive access and update the protected data.
|
|
70
|
+
* The lock is acquired before the function runs and released after it completes.
|
|
71
|
+
*
|
|
72
|
+
* @param fn - Function that receives the current data and returns the updated data
|
|
73
|
+
*/
|
|
74
|
+
async update(fn) {
|
|
75
|
+
await this.acquire();
|
|
76
|
+
try {
|
|
77
|
+
this.data = await fn(this.data);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.release();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get a snapshot of the current data without acquiring the lock.
|
|
85
|
+
* Use with caution - this does not guarantee consistency.
|
|
86
|
+
* Prefer runExclusive for reads that need to be consistent with writes.
|
|
87
|
+
*/
|
|
88
|
+
getSnapshot() {
|
|
89
|
+
return this.data;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Create initial session state data with default values.
|
|
94
|
+
*/
|
|
95
|
+
export function createInitialSessionStateData() {
|
|
96
|
+
return {
|
|
97
|
+
state: 'busy',
|
|
98
|
+
pendingState: undefined,
|
|
99
|
+
pendingStateStart: undefined,
|
|
100
|
+
autoApprovalFailed: false,
|
|
101
|
+
autoApprovalReason: undefined,
|
|
102
|
+
autoApprovalAbortController: undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -71,7 +71,9 @@ export function extractBranchParts(branchName) {
|
|
|
71
71
|
export function prepareWorktreeItems(worktrees, sessions) {
|
|
72
72
|
return worktrees.map(wt => {
|
|
73
73
|
const session = sessions.find(s => s.worktreePath === wt.path);
|
|
74
|
-
const status = session
|
|
74
|
+
const status = session
|
|
75
|
+
? ` [${getStatusDisplay(session.stateMutex.getSnapshot().state)}]`
|
|
76
|
+
: '';
|
|
75
77
|
const fullBranchName = wt.branch
|
|
76
78
|
? wt.branch.replace('refs/heads/', '')
|
|
77
79
|
: 'detached';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
|
+
import { Mutex, createInitialSessionStateData } from './mutex.js';
|
|
4
5
|
// Mock child_process module
|
|
5
6
|
vi.mock('child_process');
|
|
6
7
|
describe('generateWorktreeDirectory', () => {
|
|
@@ -121,7 +122,6 @@ describe('prepareWorktreeItems', () => {
|
|
|
121
122
|
const mockSession = {
|
|
122
123
|
id: 'test-session',
|
|
123
124
|
worktreePath: '/path/to/worktree',
|
|
124
|
-
state: 'idle',
|
|
125
125
|
process: {},
|
|
126
126
|
output: [],
|
|
127
127
|
outputHistory: [],
|
|
@@ -133,9 +133,10 @@ describe('prepareWorktreeItems', () => {
|
|
|
133
133
|
commandConfig: undefined,
|
|
134
134
|
detectionStrategy: 'claude',
|
|
135
135
|
devcontainerConfig: undefined,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
stateMutex: new Mutex({
|
|
137
|
+
...createInitialSessionStateData(),
|
|
138
|
+
state: 'idle',
|
|
139
|
+
}),
|
|
139
140
|
};
|
|
140
141
|
it('should prepare basic worktree without git status', () => {
|
|
141
142
|
const items = prepareWorktreeItems([mockWorktree], []);
|