pi-app-server 0.1.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/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/command-classification.d.ts +59 -0
- package/dist/command-classification.d.ts.map +1 -0
- package/dist/command-classification.js +78 -0
- package/dist/command-classification.js.map +7 -0
- package/dist/command-execution-engine.d.ts +118 -0
- package/dist/command-execution-engine.d.ts.map +1 -0
- package/dist/command-execution-engine.js +259 -0
- package/dist/command-execution-engine.js.map +7 -0
- package/dist/command-replay-store.d.ts +241 -0
- package/dist/command-replay-store.d.ts.map +1 -0
- package/dist/command-replay-store.js +306 -0
- package/dist/command-replay-store.js.map +7 -0
- package/dist/command-router.d.ts +25 -0
- package/dist/command-router.d.ts.map +1 -0
- package/dist/command-router.js +353 -0
- package/dist/command-router.js.map +7 -0
- package/dist/extension-ui.d.ts +139 -0
- package/dist/extension-ui.d.ts.map +1 -0
- package/dist/extension-ui.js +189 -0
- package/dist/extension-ui.js.map +7 -0
- package/dist/resource-governor.d.ts +254 -0
- package/dist/resource-governor.d.ts.map +1 -0
- package/dist/resource-governor.js +603 -0
- package/dist/resource-governor.js.map +7 -0
- package/dist/server-command-handlers.d.ts +120 -0
- package/dist/server-command-handlers.d.ts.map +1 -0
- package/dist/server-command-handlers.js +234 -0
- package/dist/server-command-handlers.js.map +7 -0
- package/dist/server-ui-context.d.ts +22 -0
- package/dist/server-ui-context.d.ts.map +1 -0
- package/dist/server-ui-context.js +221 -0
- package/dist/server-ui-context.js.map +7 -0
- package/dist/server.d.ts +82 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +561 -0
- package/dist/server.js.map +7 -0
- package/dist/session-lock-manager.d.ts +100 -0
- package/dist/session-lock-manager.d.ts.map +1 -0
- package/dist/session-lock-manager.js +199 -0
- package/dist/session-lock-manager.js.map +7 -0
- package/dist/session-manager.d.ts +196 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +1010 -0
- package/dist/session-manager.js.map +7 -0
- package/dist/session-store.d.ts +190 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +446 -0
- package/dist/session-store.js.map +7 -0
- package/dist/session-version-store.d.ts +83 -0
- package/dist/session-version-store.d.ts.map +1 -0
- package/dist/session-version-store.js +117 -0
- package/dist/session-version-store.js.map +7 -0
- package/dist/type-guards.d.ts +59 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +40 -0
- package/dist/type-guards.js.map +7 -0
- package/dist/types.d.ts +621 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +7 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +323 -0
- package/dist/validation.js.map +7 -0
- package/package.json +135 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAgentSession
|
|
3
|
+
} from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
getCommandDependsOn,
|
|
6
|
+
getCommandId,
|
|
7
|
+
getCommandIdempotencyKey,
|
|
8
|
+
getCommandIfSessionVersion,
|
|
9
|
+
getCommandType,
|
|
10
|
+
getSessionId
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { routeSessionCommand } from "./command-router.js";
|
|
13
|
+
import {
|
|
14
|
+
routeServerCommand,
|
|
15
|
+
executeLLMCommand,
|
|
16
|
+
executeBashCommand
|
|
17
|
+
} from "./server-command-handlers.js";
|
|
18
|
+
import { ExtensionUIManager } from "./extension-ui.js";
|
|
19
|
+
import { createServerUIContext } from "./server-ui-context.js";
|
|
20
|
+
import { validateCommand, formatValidationErrors } from "./validation.js";
|
|
21
|
+
import { ResourceGovernor, DEFAULT_CONFIG } from "./resource-governor.js";
|
|
22
|
+
import {
|
|
23
|
+
CommandReplayStore,
|
|
24
|
+
SYNTHETIC_ID_PREFIX
|
|
25
|
+
} from "./command-replay-store.js";
|
|
26
|
+
import { SessionVersionStore } from "./session-version-store.js";
|
|
27
|
+
import { CommandExecutionEngine } from "./command-execution-engine.js";
|
|
28
|
+
import { SessionLockManager } from "./session-lock-manager.js";
|
|
29
|
+
import { SessionStore } from "./session-store.js";
|
|
30
|
+
import { CircuitBreakerManager } from "./circuit-breaker.js";
|
|
31
|
+
import { BashCircuitBreaker } from "./bash-circuit-breaker.js";
|
|
32
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
33
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30 * 1e3;
|
|
34
|
+
const SHORT_COMMAND_TIMEOUT_MS = 30 * 1e3;
|
|
35
|
+
const DEPENDENCY_WAIT_TIMEOUT_MS = 30 * 1e3;
|
|
36
|
+
const SANITIZED_NPM_ENV_KEYS = ["npm_config_prefix", "NPM_CONFIG_PREFIX"];
|
|
37
|
+
class PiSessionManager {
|
|
38
|
+
sessions = /* @__PURE__ */ new Map();
|
|
39
|
+
sessionCreatedAt = /* @__PURE__ */ new Map();
|
|
40
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
41
|
+
unsubscribers = /* @__PURE__ */ new Map();
|
|
42
|
+
governor;
|
|
43
|
+
/** Command replay and idempotency store. */
|
|
44
|
+
replayStore;
|
|
45
|
+
/** Session version store. */
|
|
46
|
+
versionStore;
|
|
47
|
+
/** Command execution engine. */
|
|
48
|
+
executionEngine;
|
|
49
|
+
/** Session ID lock manager for preventing create/delete races. */
|
|
50
|
+
lockManager;
|
|
51
|
+
/** Session metadata store for persistence across restarts (ADR-0007). */
|
|
52
|
+
sessionStore;
|
|
53
|
+
/** Circuit breaker for LLM providers (ADR-0010). */
|
|
54
|
+
circuitBreakers;
|
|
55
|
+
/** Circuit breaker for bash commands. */
|
|
56
|
+
bashCircuitBreaker;
|
|
57
|
+
// Shutdown state (single source of truth - server.ts delegates to this)
|
|
58
|
+
isShuttingDown = false;
|
|
59
|
+
inFlightCommands = /* @__PURE__ */ new Set();
|
|
60
|
+
// Periodic cleanup timers
|
|
61
|
+
sessionExpirationTimer = null;
|
|
62
|
+
defaultCommandTimeoutMs;
|
|
63
|
+
shortCommandTimeoutMs;
|
|
64
|
+
dependencyWaitTimeoutMs;
|
|
65
|
+
// Extension UI request tracking
|
|
66
|
+
extensionUI = new ExtensionUIManager(
|
|
67
|
+
(sessionId, event) => this.broadcastEvent(sessionId, event)
|
|
68
|
+
);
|
|
69
|
+
/** Optional memory metrics provider (set by server for ADR-0016) */
|
|
70
|
+
memoryMetricsProvider = null;
|
|
71
|
+
constructor(governor, options = {}) {
|
|
72
|
+
this.governor = governor ?? new ResourceGovernor(DEFAULT_CONFIG);
|
|
73
|
+
this.defaultCommandTimeoutMs = typeof options.defaultCommandTimeoutMs === "number" && options.defaultCommandTimeoutMs > 0 ? options.defaultCommandTimeoutMs : DEFAULT_COMMAND_TIMEOUT_MS;
|
|
74
|
+
this.shortCommandTimeoutMs = typeof options.shortCommandTimeoutMs === "number" && options.shortCommandTimeoutMs > 0 ? options.shortCommandTimeoutMs : SHORT_COMMAND_TIMEOUT_MS;
|
|
75
|
+
this.dependencyWaitTimeoutMs = typeof options.dependencyWaitTimeoutMs === "number" && options.dependencyWaitTimeoutMs > 0 ? options.dependencyWaitTimeoutMs : DEPENDENCY_WAIT_TIMEOUT_MS;
|
|
76
|
+
this.replayStore = new CommandReplayStore({
|
|
77
|
+
idempotencyTtlMs: options.idempotencyTtlMs
|
|
78
|
+
});
|
|
79
|
+
this.versionStore = new SessionVersionStore();
|
|
80
|
+
this.executionEngine = new CommandExecutionEngine(
|
|
81
|
+
this.replayStore,
|
|
82
|
+
this.versionStore,
|
|
83
|
+
this,
|
|
84
|
+
// SessionResolver - the NEXUS seam
|
|
85
|
+
{
|
|
86
|
+
defaultCommandTimeoutMs: this.defaultCommandTimeoutMs,
|
|
87
|
+
shortCommandTimeoutMs: this.shortCommandTimeoutMs,
|
|
88
|
+
dependencyWaitTimeoutMs: this.dependencyWaitTimeoutMs
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
this.lockManager = new SessionLockManager();
|
|
92
|
+
this.sessionStore = new SessionStore({
|
|
93
|
+
serverVersion: options.serverVersion
|
|
94
|
+
});
|
|
95
|
+
this.circuitBreakers = new CircuitBreakerManager(options.circuitBreakerConfig);
|
|
96
|
+
this.bashCircuitBreaker = new BashCircuitBreaker(options.bashCircuitBreakerConfig);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get the resource governor for external checks (e.g., message size).
|
|
100
|
+
*/
|
|
101
|
+
getGovernor() {
|
|
102
|
+
return this.governor;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get the circuit breaker manager for external access (e.g., admin operations).
|
|
106
|
+
*/
|
|
107
|
+
getCircuitBreakers() {
|
|
108
|
+
return this.circuitBreakers;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get the bash circuit breaker for external access.
|
|
112
|
+
*/
|
|
113
|
+
getBashCircuitBreaker() {
|
|
114
|
+
return this.bashCircuitBreaker;
|
|
115
|
+
}
|
|
116
|
+
// ==========================================================================
|
|
117
|
+
// SHUTDOWN MANAGEMENT
|
|
118
|
+
// ==========================================================================
|
|
119
|
+
/**
|
|
120
|
+
* Check if the server is shutting down.
|
|
121
|
+
*/
|
|
122
|
+
isInShutdown() {
|
|
123
|
+
return this.isShuttingDown;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Set the memory metrics provider for ADR-0016 metrics system.
|
|
127
|
+
* Called by PiServer to provide access to MemorySink metrics.
|
|
128
|
+
*/
|
|
129
|
+
setMemoryMetricsProvider(provider) {
|
|
130
|
+
this.memoryMetricsProvider = provider;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Initiate graceful shutdown.
|
|
134
|
+
* - Stops accepting new commands
|
|
135
|
+
* - Broadcasts shutdown notification to all clients
|
|
136
|
+
* - Returns promise that resolves when all in-flight commands complete or timeout
|
|
137
|
+
*
|
|
138
|
+
* Idempotent: calling multiple times returns the same result.
|
|
139
|
+
*/
|
|
140
|
+
async initiateShutdown(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
141
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
142
|
+
timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
143
|
+
}
|
|
144
|
+
if (this.isShuttingDown) {
|
|
145
|
+
const remaining = this.inFlightCommands.size;
|
|
146
|
+
return { drained: 0, timedOut: remaining > 0 };
|
|
147
|
+
}
|
|
148
|
+
this.isShuttingDown = true;
|
|
149
|
+
const shutdownEvent = {
|
|
150
|
+
type: "server_shutdown",
|
|
151
|
+
data: { reason: "graceful_shutdown", timeoutMs }
|
|
152
|
+
};
|
|
153
|
+
this.broadcast(JSON.stringify(shutdownEvent));
|
|
154
|
+
const inFlightCount = this.inFlightCommands.size;
|
|
155
|
+
if (inFlightCount === 0) {
|
|
156
|
+
return { drained: 0, timedOut: false };
|
|
157
|
+
}
|
|
158
|
+
const snapshot = [...this.inFlightCommands];
|
|
159
|
+
const drainPromise = Promise.allSettled(snapshot);
|
|
160
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
const stillPending = snapshot.filter((p) => this.inFlightCommands.has(p)).length;
|
|
163
|
+
const drained = inFlightCount - stillPending;
|
|
164
|
+
resolve({ drained, timedOut: true });
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
});
|
|
167
|
+
const drainResult = new Promise((resolve) => {
|
|
168
|
+
drainPromise.then(() => {
|
|
169
|
+
resolve({ drained: inFlightCount, timedOut: false });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
return Promise.race([drainResult, timeoutPromise]);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Dispose all sessions. Call after shutdown drain completes.
|
|
176
|
+
*/
|
|
177
|
+
disposeAllSessions() {
|
|
178
|
+
let disposed = 0;
|
|
179
|
+
let failed = 0;
|
|
180
|
+
const sessionIds = [...this.sessions.keys()];
|
|
181
|
+
for (const sessionId of sessionIds) {
|
|
182
|
+
try {
|
|
183
|
+
const session = this.sessions.get(sessionId);
|
|
184
|
+
this.sessions.delete(sessionId);
|
|
185
|
+
this.sessionCreatedAt.delete(sessionId);
|
|
186
|
+
const unsubscribe = this.unsubscribers.get(sessionId);
|
|
187
|
+
if (unsubscribe) {
|
|
188
|
+
this.unsubscribers.delete(sessionId);
|
|
189
|
+
try {
|
|
190
|
+
unsubscribe();
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (session) {
|
|
195
|
+
try {
|
|
196
|
+
session.dispose();
|
|
197
|
+
disposed++;
|
|
198
|
+
} catch {
|
|
199
|
+
failed++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
failed++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.versionStore.clear();
|
|
207
|
+
this.executionEngine.clear();
|
|
208
|
+
this.replayStore.clear();
|
|
209
|
+
this.lockManager.clear();
|
|
210
|
+
this.governor.cleanupStaleData(/* @__PURE__ */ new Set());
|
|
211
|
+
return { disposed, failed };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get count of in-flight commands.
|
|
215
|
+
*/
|
|
216
|
+
getInFlightCount() {
|
|
217
|
+
return this.inFlightCommands.size;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Register an in-flight command promise for shutdown draining.
|
|
221
|
+
*/
|
|
222
|
+
registerInFlightCommand(promise) {
|
|
223
|
+
this.inFlightCommands.add(promise);
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
this.inFlightCommands.delete(promise);
|
|
226
|
+
};
|
|
227
|
+
promise.then(cleanup, cleanup);
|
|
228
|
+
}
|
|
229
|
+
broadcastCommandLifecycle(phase, data) {
|
|
230
|
+
const event = {
|
|
231
|
+
type: phase,
|
|
232
|
+
data
|
|
233
|
+
};
|
|
234
|
+
this.broadcast(JSON.stringify(event));
|
|
235
|
+
}
|
|
236
|
+
// ==========================================================================
|
|
237
|
+
// SESSION LIFECYCLE
|
|
238
|
+
// ==========================================================================
|
|
239
|
+
async createSession(sessionId, cwd) {
|
|
240
|
+
const sessionIdError = this.governor.validateSessionId(sessionId);
|
|
241
|
+
if (sessionIdError) {
|
|
242
|
+
throw new Error(sessionIdError);
|
|
243
|
+
}
|
|
244
|
+
if (cwd) {
|
|
245
|
+
const cwdError = this.governor.validateCwd(cwd);
|
|
246
|
+
if (cwdError) {
|
|
247
|
+
throw new Error(cwdError);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const lock = await this.lockManager.acquire(sessionId, "createSession");
|
|
251
|
+
try {
|
|
252
|
+
if (this.sessions.has(sessionId)) {
|
|
253
|
+
throw new Error(`Session ${sessionId} already exists`);
|
|
254
|
+
}
|
|
255
|
+
if (!this.governor.tryReserveSessionSlot()) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Session limit reached (${this.governor.getConfig().maxSessions} sessions)`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const { session } = await this.createAgentSessionWithSanitizedNpmEnv({
|
|
262
|
+
cwd: cwd ?? process.cwd()
|
|
263
|
+
});
|
|
264
|
+
await session.bindExtensions({
|
|
265
|
+
uiContext: createServerUIContext(
|
|
266
|
+
sessionId,
|
|
267
|
+
this.extensionUI,
|
|
268
|
+
(sid, event) => this.broadcastEvent(sid, event)
|
|
269
|
+
)
|
|
270
|
+
});
|
|
271
|
+
if (this.sessions.has(sessionId)) {
|
|
272
|
+
session.dispose();
|
|
273
|
+
throw new Error(`Session ${sessionId} already exists`);
|
|
274
|
+
}
|
|
275
|
+
this.sessions.set(sessionId, session);
|
|
276
|
+
this.sessionCreatedAt.set(sessionId, /* @__PURE__ */ new Date());
|
|
277
|
+
this.versionStore.initialize(sessionId);
|
|
278
|
+
this.governor.recordHeartbeat(sessionId);
|
|
279
|
+
const unsubscribe = session.subscribe((event) => {
|
|
280
|
+
this.broadcastEvent(sessionId, event);
|
|
281
|
+
});
|
|
282
|
+
this.unsubscribers.set(sessionId, unsubscribe);
|
|
283
|
+
const sessionInfo = this.getSessionInfo(sessionId);
|
|
284
|
+
if (!session.sessionFile) {
|
|
285
|
+
throw new Error("Session created without session file - cannot persist");
|
|
286
|
+
}
|
|
287
|
+
await this.sessionStore.save({
|
|
288
|
+
sessionId,
|
|
289
|
+
sessionFile: session.sessionFile,
|
|
290
|
+
cwd: cwd ?? process.cwd(),
|
|
291
|
+
createdAt: sessionInfo.createdAt,
|
|
292
|
+
modelId: session.model?.id
|
|
293
|
+
});
|
|
294
|
+
return sessionInfo;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.governor.releaseSessionSlot();
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
lock.release();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async deleteSession(sessionId) {
|
|
304
|
+
const lock = await this.lockManager.acquire(sessionId, "deleteSession");
|
|
305
|
+
try {
|
|
306
|
+
const session = this.sessions.get(sessionId);
|
|
307
|
+
if (!session) {
|
|
308
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
309
|
+
}
|
|
310
|
+
this.extensionUI.cancelSessionRequests(sessionId);
|
|
311
|
+
this.sessions.delete(sessionId);
|
|
312
|
+
this.sessionCreatedAt.delete(sessionId);
|
|
313
|
+
this.versionStore.delete(sessionId);
|
|
314
|
+
this.governor.unregisterSession(sessionId);
|
|
315
|
+
this.governor.cleanupStaleData(new Set(this.sessions.keys()));
|
|
316
|
+
const unsubscribe = this.unsubscribers.get(sessionId);
|
|
317
|
+
if (unsubscribe) {
|
|
318
|
+
this.unsubscribers.delete(sessionId);
|
|
319
|
+
try {
|
|
320
|
+
unsubscribe();
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.error(`[deleteSession] Failed to unsubscribe:`, error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
session.dispose();
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.error(`[deleteSession] Failed to dispose session:`, error);
|
|
329
|
+
}
|
|
330
|
+
for (const subscriber of this.subscribers) {
|
|
331
|
+
subscriber.subscribedSessions.delete(sessionId);
|
|
332
|
+
}
|
|
333
|
+
await this.sessionStore.delete(sessionId);
|
|
334
|
+
} finally {
|
|
335
|
+
lock.release();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
getSession(sessionId) {
|
|
339
|
+
return this.sessions.get(sessionId);
|
|
340
|
+
}
|
|
341
|
+
getSessionInfo(sessionId) {
|
|
342
|
+
const session = this.sessions.get(sessionId);
|
|
343
|
+
if (!session) return void 0;
|
|
344
|
+
const createdAt = this.sessionCreatedAt.get(sessionId);
|
|
345
|
+
if (!createdAt) {
|
|
346
|
+
console.error(`[getSessionInfo] Missing createdAt for session ${sessionId}`);
|
|
347
|
+
return void 0;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
sessionId,
|
|
351
|
+
sessionName: session.sessionName,
|
|
352
|
+
sessionFile: session.sessionFile,
|
|
353
|
+
model: session.model,
|
|
354
|
+
thinkingLevel: session.thinkingLevel,
|
|
355
|
+
isStreaming: session.isStreaming,
|
|
356
|
+
messageCount: session.messages.length,
|
|
357
|
+
createdAt: createdAt.toISOString()
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
listSessions() {
|
|
361
|
+
const infos = [];
|
|
362
|
+
for (const sessionId of this.sessions.keys()) {
|
|
363
|
+
const info = this.getSessionInfo(sessionId);
|
|
364
|
+
if (info) infos.push(info);
|
|
365
|
+
}
|
|
366
|
+
return infos;
|
|
367
|
+
}
|
|
368
|
+
// ==========================================================================
|
|
369
|
+
// SESSION PERSISTENCE (ADR-0007)
|
|
370
|
+
// ==========================================================================
|
|
371
|
+
/**
|
|
372
|
+
* List stored sessions that can be loaded.
|
|
373
|
+
* These are sessions that existed in previous server runs OR discovered on disk.
|
|
374
|
+
*/
|
|
375
|
+
async listStoredSessions() {
|
|
376
|
+
return this.sessionStore.listAllSessions();
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Load a session from a stored session file.
|
|
380
|
+
* Creates a new in-memory session that reads from the existing session file.
|
|
381
|
+
*/
|
|
382
|
+
async loadSession(sessionId, sessionPath) {
|
|
383
|
+
const sessionIdError = this.governor.validateSessionId(sessionId);
|
|
384
|
+
if (sessionIdError) {
|
|
385
|
+
throw new Error(sessionIdError);
|
|
386
|
+
}
|
|
387
|
+
const lock = await this.lockManager.acquire(sessionId, "loadSession");
|
|
388
|
+
try {
|
|
389
|
+
if (this.sessions.has(sessionId)) {
|
|
390
|
+
throw new Error(`Session ${sessionId} already exists`);
|
|
391
|
+
}
|
|
392
|
+
if (!this.governor.tryReserveSessionSlot()) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Session limit reached (${this.governor.getConfig().maxSessions} sessions)`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const { session } = await this.createAgentSessionWithSanitizedNpmEnv({
|
|
399
|
+
cwd: process.cwd()
|
|
400
|
+
});
|
|
401
|
+
const switched = await session.switchSession(sessionPath);
|
|
402
|
+
if (!switched) {
|
|
403
|
+
session.dispose();
|
|
404
|
+
throw new Error(`Failed to load session from ${sessionPath}`);
|
|
405
|
+
}
|
|
406
|
+
await session.bindExtensions({
|
|
407
|
+
uiContext: createServerUIContext(
|
|
408
|
+
sessionId,
|
|
409
|
+
this.extensionUI,
|
|
410
|
+
(sid, event) => this.broadcastEvent(sid, event)
|
|
411
|
+
)
|
|
412
|
+
});
|
|
413
|
+
if (this.sessions.has(sessionId)) {
|
|
414
|
+
session.dispose();
|
|
415
|
+
throw new Error(`Session ${sessionId} already exists`);
|
|
416
|
+
}
|
|
417
|
+
this.sessions.set(sessionId, session);
|
|
418
|
+
this.sessionCreatedAt.set(sessionId, /* @__PURE__ */ new Date());
|
|
419
|
+
this.versionStore.initialize(sessionId);
|
|
420
|
+
this.governor.recordHeartbeat(sessionId);
|
|
421
|
+
const unsubscribe = session.subscribe((event) => {
|
|
422
|
+
this.broadcastEvent(sessionId, event);
|
|
423
|
+
});
|
|
424
|
+
this.unsubscribers.set(sessionId, unsubscribe);
|
|
425
|
+
const sessionInfo = this.getSessionInfo(sessionId);
|
|
426
|
+
if (!session.sessionFile) {
|
|
427
|
+
throw new Error("Session loaded without session file - cannot persist metadata");
|
|
428
|
+
}
|
|
429
|
+
await this.sessionStore.save({
|
|
430
|
+
sessionId,
|
|
431
|
+
sessionFile: session.sessionFile,
|
|
432
|
+
cwd: process.cwd(),
|
|
433
|
+
createdAt: sessionInfo.createdAt,
|
|
434
|
+
modelId: session.model?.id
|
|
435
|
+
});
|
|
436
|
+
return sessionInfo;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
this.governor.releaseSessionSlot();
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
lock.release();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get the session store for direct access (e.g., cleanup).
|
|
447
|
+
*/
|
|
448
|
+
getSessionStore() {
|
|
449
|
+
return this.sessionStore;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Start periodic cleanup of orphaned session metadata and expired sessions.
|
|
453
|
+
* @param intervalMs Cleanup interval in milliseconds (default: 1 hour)
|
|
454
|
+
*/
|
|
455
|
+
startSessionCleanup(intervalMs) {
|
|
456
|
+
this.sessionStore.startPeriodicCleanup(intervalMs);
|
|
457
|
+
this.startSessionExpirationCheck(intervalMs);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Stop periodic cleanup.
|
|
461
|
+
*/
|
|
462
|
+
stopSessionCleanup() {
|
|
463
|
+
this.sessionStore.stopPeriodicCleanup();
|
|
464
|
+
this.stopSessionExpirationCheck();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Run a one-time cleanup of orphaned session metadata and expired sessions.
|
|
468
|
+
*/
|
|
469
|
+
async cleanupSessions() {
|
|
470
|
+
await this.cleanupExpiredSessions();
|
|
471
|
+
return this.sessionStore.cleanup();
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Start periodic check for expired sessions (maxSessionLifetimeMs).
|
|
475
|
+
*/
|
|
476
|
+
startSessionExpirationCheck(intervalMs = 36e5) {
|
|
477
|
+
if (this.sessionExpirationTimer) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.sessionExpirationTimer = setInterval(() => {
|
|
481
|
+
this.cleanupExpiredSessions().catch((error) => {
|
|
482
|
+
console.error("[SessionManager] Session expiration cleanup failed:", error);
|
|
483
|
+
});
|
|
484
|
+
}, intervalMs);
|
|
485
|
+
if (this.sessionExpirationTimer.unref) {
|
|
486
|
+
this.sessionExpirationTimer.unref();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Stop periodic session expiration check.
|
|
491
|
+
*/
|
|
492
|
+
stopSessionExpirationCheck() {
|
|
493
|
+
if (this.sessionExpirationTimer) {
|
|
494
|
+
clearInterval(this.sessionExpirationTimer);
|
|
495
|
+
this.sessionExpirationTimer = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Clean up sessions that have exceeded maxSessionLifetimeMs.
|
|
500
|
+
* Also cleans up stale circuit breakers for unused providers.
|
|
501
|
+
*/
|
|
502
|
+
async cleanupExpiredSessions() {
|
|
503
|
+
const expiredIds = this.governor.getExpiredSessions();
|
|
504
|
+
for (const sessionId of expiredIds) {
|
|
505
|
+
try {
|
|
506
|
+
console.error(`[SessionManager] Deleting expired session: ${sessionId}`);
|
|
507
|
+
await this.deleteSession(sessionId);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error(`[SessionManager] Failed to delete expired session ${sessionId}:`, error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const staleBreakersRemoved = this.circuitBreakers.cleanupStaleBreakers();
|
|
513
|
+
if (staleBreakersRemoved > 0) {
|
|
514
|
+
console.error(`[SessionManager] Cleaned up ${staleBreakersRemoved} stale circuit breakers`);
|
|
515
|
+
}
|
|
516
|
+
const staleBashBreakersRemoved = this.bashCircuitBreaker.cleanupStale();
|
|
517
|
+
if (staleBashBreakersRemoved > 0) {
|
|
518
|
+
console.error(
|
|
519
|
+
`[SessionManager] Cleaned up ${staleBashBreakersRemoved} stale bash circuit breakers`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// ==========================================================================
|
|
524
|
+
// SUBSCRIBER MANAGEMENT
|
|
525
|
+
// ==========================================================================
|
|
526
|
+
addSubscriber(subscriber) {
|
|
527
|
+
this.subscribers.add(subscriber);
|
|
528
|
+
}
|
|
529
|
+
removeSubscriber(subscriber) {
|
|
530
|
+
this.subscribers.delete(subscriber);
|
|
531
|
+
}
|
|
532
|
+
subscribeToSession(subscriber, sessionId) {
|
|
533
|
+
if (!this.sessions.has(sessionId)) {
|
|
534
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
535
|
+
}
|
|
536
|
+
subscriber.subscribedSessions.add(sessionId);
|
|
537
|
+
}
|
|
538
|
+
unsubscribeFromSession(subscriber, sessionId) {
|
|
539
|
+
subscriber.subscribedSessions.delete(sessionId);
|
|
540
|
+
}
|
|
541
|
+
// ==========================================================================
|
|
542
|
+
// EVENT BROADCAST
|
|
543
|
+
// ==========================================================================
|
|
544
|
+
broadcastEvent(sessionId, event) {
|
|
545
|
+
const rpcEvent = {
|
|
546
|
+
type: "event",
|
|
547
|
+
sessionId,
|
|
548
|
+
event
|
|
549
|
+
};
|
|
550
|
+
let data;
|
|
551
|
+
try {
|
|
552
|
+
data = JSON.stringify(rpcEvent);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
console.error(`[broadcastEvent] JSON serialization failed:`, error);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const snapshot = [...this.subscribers];
|
|
558
|
+
for (const subscriber of snapshot) {
|
|
559
|
+
if (subscriber.subscribedSessions.has(sessionId)) {
|
|
560
|
+
try {
|
|
561
|
+
subscriber.send(data);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
console.error(`[broadcastEvent] Failed to send to subscriber:`, error);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
broadcast(data) {
|
|
569
|
+
const snapshot = [...this.subscribers];
|
|
570
|
+
for (const subscriber of snapshot) {
|
|
571
|
+
try {
|
|
572
|
+
subscriber.send(data);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
console.error(`[broadcast] Failed to send to subscriber:`, error);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// ==========================================================================
|
|
579
|
+
// COMMAND EXECUTION CONTEXT
|
|
580
|
+
// ==========================================================================
|
|
581
|
+
/**
|
|
582
|
+
* Create the command execution context for server command handlers.
|
|
583
|
+
* This is the NEXUS seam - provides everything handlers need without
|
|
584
|
+
* direct coupling to SessionManager internals.
|
|
585
|
+
*/
|
|
586
|
+
createCommandContext() {
|
|
587
|
+
return {
|
|
588
|
+
getSession: (sessionId) => this.sessions.get(sessionId),
|
|
589
|
+
getSessionInfo: (sessionId) => this.getSessionInfo(sessionId),
|
|
590
|
+
listSessions: () => this.listSessions(),
|
|
591
|
+
createSession: (sessionId, cwd) => this.createSession(sessionId, cwd),
|
|
592
|
+
deleteSession: (sessionId) => this.deleteSession(sessionId),
|
|
593
|
+
loadSession: (sessionId, sessionPath) => this.loadSession(sessionId, sessionPath),
|
|
594
|
+
listStoredSessions: () => this.listStoredSessions(),
|
|
595
|
+
getMetrics: () => this.buildMetricsResponse(),
|
|
596
|
+
getMemoryMetrics: () => this.memoryMetricsProvider?.(),
|
|
597
|
+
getHealth: () => this.buildHealthResponse(),
|
|
598
|
+
handleUIResponse: (command) => this.extensionUI.handleUIResponse({
|
|
599
|
+
id: command.id,
|
|
600
|
+
sessionId: command.sessionId,
|
|
601
|
+
type: "extension_ui_response",
|
|
602
|
+
requestId: command.requestId,
|
|
603
|
+
response: command.response
|
|
604
|
+
}),
|
|
605
|
+
routeSessionCommand: (session, command, getSessionInfo) => routeSessionCommand(session, command, getSessionInfo),
|
|
606
|
+
generateSessionId: () => this.generateSessionId(),
|
|
607
|
+
recordHeartbeat: (sessionId) => this.governor.recordHeartbeat(sessionId),
|
|
608
|
+
getCircuitBreakers: () => ({
|
|
609
|
+
hasOpenCircuit: () => this.circuitBreakers.hasOpenCircuit(),
|
|
610
|
+
getBreaker: (provider) => {
|
|
611
|
+
const breaker = this.circuitBreakers.getBreaker(provider);
|
|
612
|
+
return {
|
|
613
|
+
canExecute: () => breaker.canExecute(),
|
|
614
|
+
recordSuccess: (elapsedMs) => breaker.recordSuccess(elapsedMs),
|
|
615
|
+
recordFailure: (type) => breaker.recordFailure(type)
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}),
|
|
619
|
+
getBashCircuitBreaker: () => ({
|
|
620
|
+
canExecute: (sessionId) => this.bashCircuitBreaker.canExecute(sessionId),
|
|
621
|
+
recordSuccess: (sessionId) => this.bashCircuitBreaker.recordSuccess(sessionId),
|
|
622
|
+
recordTimeout: (sessionId) => this.bashCircuitBreaker.recordTimeout(sessionId),
|
|
623
|
+
recordSpawnError: (sessionId) => this.bashCircuitBreaker.recordSpawnError(sessionId),
|
|
624
|
+
hasOpenCircuit: () => this.bashCircuitBreaker.hasOpenCircuit(),
|
|
625
|
+
getMetrics: () => this.bashCircuitBreaker.getMetrics()
|
|
626
|
+
}),
|
|
627
|
+
getDefaultCommandTimeoutMs: () => this.defaultCommandTimeoutMs
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Build the metrics response (extracted for handler use).
|
|
632
|
+
*/
|
|
633
|
+
buildMetricsResponse() {
|
|
634
|
+
const governorMetrics = this.governor.getMetrics();
|
|
635
|
+
const replayStats = this.replayStore.getStats();
|
|
636
|
+
const versionStats = this.versionStore.getStats();
|
|
637
|
+
const executionStats = this.executionEngine.getStats();
|
|
638
|
+
const lockStats = this.lockManager.getStats();
|
|
639
|
+
const extensionUIStats = this.extensionUI.getStats();
|
|
640
|
+
const circuitBreakerMetrics = this.circuitBreakers.getAllMetrics();
|
|
641
|
+
const bashCircuitBreakerMetrics = this.bashCircuitBreaker.getMetrics();
|
|
642
|
+
const sessionStoreStats = {
|
|
643
|
+
metadataResetCount: this.sessionStore.getMetadataResetCount()
|
|
644
|
+
};
|
|
645
|
+
return {
|
|
646
|
+
type: "response",
|
|
647
|
+
command: "get_metrics",
|
|
648
|
+
success: true,
|
|
649
|
+
data: {
|
|
650
|
+
...governorMetrics,
|
|
651
|
+
stores: {
|
|
652
|
+
replay: replayStats,
|
|
653
|
+
version: versionStats,
|
|
654
|
+
execution: executionStats,
|
|
655
|
+
lock: lockStats,
|
|
656
|
+
extensionUI: extensionUIStats,
|
|
657
|
+
sessionStore: sessionStoreStats
|
|
658
|
+
},
|
|
659
|
+
circuitBreakers: circuitBreakerMetrics,
|
|
660
|
+
bashCircuitBreaker: bashCircuitBreakerMetrics
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Build the health check response (extracted for handler use).
|
|
666
|
+
*/
|
|
667
|
+
buildHealthResponse() {
|
|
668
|
+
const health = this.governor.isHealthy();
|
|
669
|
+
const hasOpenCircuit = this.circuitBreakers.hasOpenCircuit();
|
|
670
|
+
const hasOpenBashCircuit = this.bashCircuitBreaker.hasOpenCircuit();
|
|
671
|
+
const issues = [...health.issues];
|
|
672
|
+
if (hasOpenCircuit) {
|
|
673
|
+
issues.push("One or more LLM provider circuits are open");
|
|
674
|
+
}
|
|
675
|
+
if (hasOpenBashCircuit) {
|
|
676
|
+
issues.push("Bash command circuit breaker is open");
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
type: "response",
|
|
680
|
+
command: "health_check",
|
|
681
|
+
success: true,
|
|
682
|
+
data: {
|
|
683
|
+
...health,
|
|
684
|
+
hasOpenCircuit,
|
|
685
|
+
hasOpenBashCircuit,
|
|
686
|
+
issues
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
// ==========================================================================
|
|
691
|
+
// COMMAND EXECUTION
|
|
692
|
+
// ==========================================================================
|
|
693
|
+
async executeCommand(command) {
|
|
694
|
+
const id = getCommandId(command);
|
|
695
|
+
const commandType = getCommandType(command);
|
|
696
|
+
const sessionId = getSessionId(command);
|
|
697
|
+
const commandId = this.replayStore.getOrCreateCommandId(command);
|
|
698
|
+
const dependsOn = getCommandDependsOn(command) ?? [];
|
|
699
|
+
const ifSessionVersion = getCommandIfSessionVersion(command);
|
|
700
|
+
const idempotencyKey = getCommandIdempotencyKey(command);
|
|
701
|
+
const laneKey = this.executionEngine.getLaneKey(command);
|
|
702
|
+
const fingerprint = this.replayStore.getCommandFingerprint(command);
|
|
703
|
+
if (this.isShuttingDown) {
|
|
704
|
+
return {
|
|
705
|
+
id,
|
|
706
|
+
type: "response",
|
|
707
|
+
command: commandType ?? "unknown",
|
|
708
|
+
success: false,
|
|
709
|
+
error: "Server is shutting down"
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const validationErrors = validateCommand(command);
|
|
713
|
+
if (validationErrors.length > 0) {
|
|
714
|
+
return {
|
|
715
|
+
id,
|
|
716
|
+
type: "response",
|
|
717
|
+
command: commandType ?? "unknown",
|
|
718
|
+
success: false,
|
|
719
|
+
error: `Validation failed: ${formatValidationErrors(validationErrors)}`
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
this.replayStore.cleanupIdempotencyCache();
|
|
723
|
+
this.broadcastCommandLifecycle("command_accepted", {
|
|
724
|
+
commandId,
|
|
725
|
+
commandType,
|
|
726
|
+
sessionId,
|
|
727
|
+
dependsOn,
|
|
728
|
+
ifSessionVersion,
|
|
729
|
+
idempotencyKey
|
|
730
|
+
});
|
|
731
|
+
const finalizeResponse = (response2) => {
|
|
732
|
+
this.broadcastCommandLifecycle("command_finished", {
|
|
733
|
+
commandId,
|
|
734
|
+
commandType,
|
|
735
|
+
sessionId,
|
|
736
|
+
dependsOn,
|
|
737
|
+
ifSessionVersion,
|
|
738
|
+
idempotencyKey,
|
|
739
|
+
success: response2.success,
|
|
740
|
+
error: response2.success ? void 0 : response2.error,
|
|
741
|
+
sessionVersion: response2.sessionVersion,
|
|
742
|
+
replayed: response2.replayed
|
|
743
|
+
});
|
|
744
|
+
return response2;
|
|
745
|
+
};
|
|
746
|
+
const replayCheck = this.replayStore.checkReplay(command, fingerprint);
|
|
747
|
+
if (replayCheck.kind === "conflict") {
|
|
748
|
+
return finalizeResponse(replayCheck.response);
|
|
749
|
+
}
|
|
750
|
+
if (replayCheck.kind === "replay_cached") {
|
|
751
|
+
return finalizeResponse(replayCheck.response);
|
|
752
|
+
}
|
|
753
|
+
if (replayCheck.kind === "replay_inflight") {
|
|
754
|
+
const replayed = await replayCheck.promise;
|
|
755
|
+
return finalizeResponse(replayed);
|
|
756
|
+
}
|
|
757
|
+
const rateLimitKey = sessionId ?? "_server_";
|
|
758
|
+
const rateLimitResult = this.governor.canExecuteCommand(rateLimitKey);
|
|
759
|
+
if (!rateLimitResult.allowed) {
|
|
760
|
+
return finalizeResponse({
|
|
761
|
+
id,
|
|
762
|
+
type: "response",
|
|
763
|
+
command: commandType,
|
|
764
|
+
success: false,
|
|
765
|
+
error: rateLimitResult.reason
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (commandType === "extension_ui_response" && sessionId) {
|
|
769
|
+
const extRateLimitResult = this.governor.canExecuteExtensionUIResponse(sessionId);
|
|
770
|
+
if (!extRateLimitResult.allowed) {
|
|
771
|
+
return finalizeResponse({
|
|
772
|
+
id,
|
|
773
|
+
type: "response",
|
|
774
|
+
command: commandType,
|
|
775
|
+
success: false,
|
|
776
|
+
error: extRateLimitResult.reason
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const isExplicitId = typeof id === "string" && !id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
781
|
+
if (isExplicitId && !this.replayStore.canRegisterInFlight(id)) {
|
|
782
|
+
return finalizeResponse({
|
|
783
|
+
id,
|
|
784
|
+
type: "response",
|
|
785
|
+
command: commandType,
|
|
786
|
+
success: false,
|
|
787
|
+
error: "Server busy - too many concurrent commands. Please retry."
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const commandExecution = this.executionEngine.runOnLane(
|
|
791
|
+
laneKey,
|
|
792
|
+
async () => {
|
|
793
|
+
this.broadcastCommandLifecycle("command_started", {
|
|
794
|
+
commandId,
|
|
795
|
+
commandType,
|
|
796
|
+
sessionId,
|
|
797
|
+
dependsOn,
|
|
798
|
+
ifSessionVersion,
|
|
799
|
+
idempotencyKey
|
|
800
|
+
});
|
|
801
|
+
if (dependsOn.includes(commandId)) {
|
|
802
|
+
return {
|
|
803
|
+
id,
|
|
804
|
+
type: "response",
|
|
805
|
+
command: commandType,
|
|
806
|
+
success: false,
|
|
807
|
+
error: `Command '${commandId}' cannot depend on itself`
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
if (dependsOn.length > 0) {
|
|
811
|
+
const dependencyResult = await this.executionEngine.awaitDependencies(dependsOn, laneKey);
|
|
812
|
+
if (!dependencyResult.ok) {
|
|
813
|
+
return {
|
|
814
|
+
id,
|
|
815
|
+
type: "response",
|
|
816
|
+
command: commandType,
|
|
817
|
+
success: false,
|
|
818
|
+
error: dependencyResult.error
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (sessionId !== void 0 && ifSessionVersion !== void 0) {
|
|
823
|
+
const versionError = this.executionEngine.checkSessionVersion(
|
|
824
|
+
sessionId,
|
|
825
|
+
ifSessionVersion,
|
|
826
|
+
commandType
|
|
827
|
+
);
|
|
828
|
+
if (versionError) {
|
|
829
|
+
return {
|
|
830
|
+
id,
|
|
831
|
+
type: "response",
|
|
832
|
+
command: commandType,
|
|
833
|
+
success: false,
|
|
834
|
+
error: versionError.error
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const rawResponse = await this.executeCommandInternal(command, id, commandType);
|
|
839
|
+
return this.versionStore.applyVersion(command, rawResponse);
|
|
840
|
+
}
|
|
841
|
+
);
|
|
842
|
+
let inFlightRecord;
|
|
843
|
+
if (id) {
|
|
844
|
+
inFlightRecord = {
|
|
845
|
+
commandType,
|
|
846
|
+
laneKey,
|
|
847
|
+
fingerprint,
|
|
848
|
+
promise: commandExecution
|
|
849
|
+
};
|
|
850
|
+
const registered = this.replayStore.registerInFlight(id, inFlightRecord);
|
|
851
|
+
if (!registered) {
|
|
852
|
+
return finalizeResponse({
|
|
853
|
+
id,
|
|
854
|
+
type: "response",
|
|
855
|
+
command: commandType,
|
|
856
|
+
success: false,
|
|
857
|
+
error: "Server busy - too many concurrent commands. Please retry."
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
this.registerInFlightCommand(commandExecution);
|
|
862
|
+
let response;
|
|
863
|
+
try {
|
|
864
|
+
response = await this.executionEngine.executeWithTimeout(
|
|
865
|
+
commandType,
|
|
866
|
+
commandExecution,
|
|
867
|
+
command
|
|
868
|
+
);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
response = {
|
|
871
|
+
id,
|
|
872
|
+
type: "response",
|
|
873
|
+
command: commandType,
|
|
874
|
+
success: false,
|
|
875
|
+
error: error instanceof Error ? error.message : String(error),
|
|
876
|
+
timedOut: true
|
|
877
|
+
// Mark as timeout for debugging
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
if (isExplicitId) {
|
|
881
|
+
try {
|
|
882
|
+
this.replayStore.storeCommandOutcome({
|
|
883
|
+
commandId: id,
|
|
884
|
+
commandType,
|
|
885
|
+
laneKey,
|
|
886
|
+
fingerprint,
|
|
887
|
+
success: response.success,
|
|
888
|
+
error: response.success ? void 0 : response.error,
|
|
889
|
+
response,
|
|
890
|
+
sessionVersion: response.sessionVersion,
|
|
891
|
+
finishedAt: Date.now()
|
|
892
|
+
});
|
|
893
|
+
} catch (outcomeError) {
|
|
894
|
+
console.error(`[executeCommand] Failed to store command outcome for ${id}:`, outcomeError);
|
|
895
|
+
}
|
|
896
|
+
if (this.replayStore.getInFlight(id) === inFlightRecord) {
|
|
897
|
+
this.replayStore.unregisterInFlight(id, inFlightRecord);
|
|
898
|
+
}
|
|
899
|
+
} else if (id && this.replayStore.getInFlight(id) === inFlightRecord) {
|
|
900
|
+
this.replayStore.unregisterInFlight(id, inFlightRecord);
|
|
901
|
+
}
|
|
902
|
+
if (idempotencyKey) {
|
|
903
|
+
this.replayStore.cacheIdempotencyResult({
|
|
904
|
+
command,
|
|
905
|
+
idempotencyKey,
|
|
906
|
+
commandType,
|
|
907
|
+
fingerprint,
|
|
908
|
+
response
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return finalizeResponse(response);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Internal command execution (called after tracking and rate limiting).
|
|
915
|
+
* Routes to server command handlers or session command handlers.
|
|
916
|
+
*/
|
|
917
|
+
async executeCommandInternal(command, id, commandType) {
|
|
918
|
+
const failResponse = (error, responseCommand = commandType) => {
|
|
919
|
+
return {
|
|
920
|
+
id,
|
|
921
|
+
type: "response",
|
|
922
|
+
command: responseCommand,
|
|
923
|
+
success: false,
|
|
924
|
+
error
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
try {
|
|
928
|
+
const context = this.createCommandContext();
|
|
929
|
+
const serverResponse = routeServerCommand(command, context);
|
|
930
|
+
if (serverResponse !== void 0) {
|
|
931
|
+
const resolved = await Promise.resolve(serverResponse);
|
|
932
|
+
return { ...resolved, id };
|
|
933
|
+
}
|
|
934
|
+
const cmdSessionId = getSessionId(command);
|
|
935
|
+
const session = this.sessions.get(cmdSessionId);
|
|
936
|
+
if (!session) {
|
|
937
|
+
return failResponse(`Session ${cmdSessionId} not found`);
|
|
938
|
+
}
|
|
939
|
+
this.governor.recordHeartbeat(cmdSessionId);
|
|
940
|
+
const llmResponse = await executeLLMCommand(command, session, context);
|
|
941
|
+
if (llmResponse !== void 0) {
|
|
942
|
+
if (!llmResponse.success) {
|
|
943
|
+
return { ...llmResponse, id };
|
|
944
|
+
}
|
|
945
|
+
return llmResponse;
|
|
946
|
+
}
|
|
947
|
+
const bashResponse = await executeBashCommand(command, session, context);
|
|
948
|
+
if (bashResponse !== void 0) {
|
|
949
|
+
if (!bashResponse.success) {
|
|
950
|
+
return { ...bashResponse, id };
|
|
951
|
+
}
|
|
952
|
+
return bashResponse;
|
|
953
|
+
}
|
|
954
|
+
const routed = routeSessionCommand(session, command, (sid) => this.getSessionInfo(sid));
|
|
955
|
+
if (routed === void 0) {
|
|
956
|
+
return failResponse(`Unknown command type: ${commandType}`);
|
|
957
|
+
}
|
|
958
|
+
const response = await Promise.resolve(routed);
|
|
959
|
+
if (!response.success) {
|
|
960
|
+
return failResponse(response.error ?? "Unknown error", response.command);
|
|
961
|
+
}
|
|
962
|
+
return response;
|
|
963
|
+
} catch (error) {
|
|
964
|
+
return failResponse(error instanceof Error ? error.message : String(error));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// ==========================================================================
|
|
968
|
+
// UTILITIES
|
|
969
|
+
// ==========================================================================
|
|
970
|
+
/**
|
|
971
|
+
* Create an AgentSession while sanitizing npm prefix env leakage from npm scripts.
|
|
972
|
+
*
|
|
973
|
+
* npm sets npm_config_prefix for child processes. If inherited here,
|
|
974
|
+
* pi-coding-agent's global package installation can be redirected into the
|
|
975
|
+
* current project (e.g. ./lib/node_modules), causing flaky session creation.
|
|
976
|
+
*/
|
|
977
|
+
async createAgentSessionWithSanitizedNpmEnv(options) {
|
|
978
|
+
const snapshots = SANITIZED_NPM_ENV_KEYS.map((key) => ({
|
|
979
|
+
key,
|
|
980
|
+
had: Object.hasOwn(process.env, key),
|
|
981
|
+
value: process.env[key]
|
|
982
|
+
}));
|
|
983
|
+
for (const snapshot of snapshots) {
|
|
984
|
+
if (snapshot.had) {
|
|
985
|
+
delete process.env[snapshot.key];
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
return await createAgentSession(options);
|
|
990
|
+
} finally {
|
|
991
|
+
for (const snapshot of snapshots) {
|
|
992
|
+
if (!snapshot.had) continue;
|
|
993
|
+
if (snapshot.value === void 0) {
|
|
994
|
+
delete process.env[snapshot.key];
|
|
995
|
+
} else {
|
|
996
|
+
process.env[snapshot.key] = snapshot.value;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
generateSessionId() {
|
|
1002
|
+
const timestamp = Date.now().toString(36);
|
|
1003
|
+
const random = crypto.randomUUID().split("-")[0];
|
|
1004
|
+
return `session-${timestamp}-${random}`;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
export {
|
|
1008
|
+
PiSessionManager
|
|
1009
|
+
};
|
|
1010
|
+
//# sourceMappingURL=session-manager.js.map
|