aegis-bridge 2.5.4 → 2.5.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/pipeline.d.ts +1 -0
- package/dist/pipeline.js +11 -2
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/server.js +10 -4
- package/dist/session.d.ts +2 -2
- package/dist/session.js +6 -14
- package/package.json +2 -1
package/dist/pipeline.d.ts
CHANGED
package/dist/pipeline.js
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* sequential pipelines with stage dependencies.
|
|
6
6
|
*/
|
|
7
7
|
import { getErrorMessage } from './validation.js';
|
|
8
|
+
import { shouldRetry } from './error-categories.js';
|
|
9
|
+
import { retryWithJitter } from './retry.js';
|
|
8
10
|
export class PipelineManager {
|
|
9
11
|
sessions;
|
|
10
12
|
eventBus;
|
|
13
|
+
static PIPELINE_RETRY_MAX_ATTEMPTS = 3;
|
|
11
14
|
pipelines = new Map();
|
|
12
15
|
pipelineConfigs = new Map(); // #219: preserve original stage config
|
|
13
16
|
pollInterval = null;
|
|
@@ -127,14 +130,20 @@ export class PipelineManager {
|
|
|
127
130
|
if (!stageConfig)
|
|
128
131
|
continue;
|
|
129
132
|
try {
|
|
130
|
-
const session = await this.sessions.createSession({
|
|
133
|
+
const session = await retryWithJitter(async () => this.sessions.createSession({
|
|
131
134
|
workDir: stageConfig.workDir || config.workDir,
|
|
132
135
|
name: `pipeline-${config.name}-${stage.name}`,
|
|
133
136
|
permissionMode: stageConfig.permissionMode,
|
|
134
137
|
autoApprove: stageConfig.autoApprove,
|
|
138
|
+
}), {
|
|
139
|
+
maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
|
|
140
|
+
shouldRetry: (error) => shouldRetry(error),
|
|
135
141
|
});
|
|
136
142
|
if (stageConfig.prompt) {
|
|
137
|
-
await this.sessions.sendInitialPrompt(session.id, stageConfig.prompt)
|
|
143
|
+
await retryWithJitter(async () => this.sessions.sendInitialPrompt(session.id, stageConfig.prompt), {
|
|
144
|
+
maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
|
|
145
|
+
shouldRetry: (error) => shouldRetry(error),
|
|
146
|
+
});
|
|
138
147
|
}
|
|
139
148
|
stage.sessionId = session.id;
|
|
140
149
|
stage.status = 'running';
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry.ts — shared retry helper with bounded exponential backoff + jitter.
|
|
3
|
+
*/
|
|
4
|
+
export interface RetryOptions {
|
|
5
|
+
maxAttempts?: number;
|
|
6
|
+
baseDelayMs?: number;
|
|
7
|
+
maxDelayMs?: number;
|
|
8
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
9
|
+
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function retryWithJitter<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry.ts — shared retry helper with bounded exponential backoff + jitter.
|
|
3
|
+
*/
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
function computeDelayMs(attempt, baseDelayMs, maxDelayMs) {
|
|
8
|
+
const exponential = Math.min(baseDelayMs * (2 ** (attempt - 1)), maxDelayMs);
|
|
9
|
+
const jitterMultiplier = 0.5 + (Math.random() * 0.5);
|
|
10
|
+
return Math.round(exponential * jitterMultiplier);
|
|
11
|
+
}
|
|
12
|
+
export async function retryWithJitter(fn, options = {}) {
|
|
13
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
14
|
+
const baseDelayMs = options.baseDelayMs ?? 250;
|
|
15
|
+
const maxDelayMs = options.maxDelayMs ?? 3_000;
|
|
16
|
+
let lastError;
|
|
17
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
18
|
+
try {
|
|
19
|
+
return await fn();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
lastError = error;
|
|
23
|
+
const isLastAttempt = attempt >= maxAttempts;
|
|
24
|
+
const canRetry = options.shouldRetry ? options.shouldRetry(error, attempt) : true;
|
|
25
|
+
if (isLastAttempt || !canRetry) {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
const delayMs = computeDelayMs(attempt, baseDelayMs, maxDelayMs);
|
|
29
|
+
options.onRetry?.(error, attempt, delayMs);
|
|
30
|
+
await sleep(delayMs);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw lastError;
|
|
34
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -70,8 +70,10 @@ async function handleInbound(cmd) {
|
|
|
70
70
|
await sessions.escape(cmd.sessionId);
|
|
71
71
|
break;
|
|
72
72
|
case 'kill':
|
|
73
|
-
|
|
73
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
74
|
+
// reference a session that is still being destroyed.
|
|
74
75
|
await sessions.killSession(cmd.sessionId);
|
|
76
|
+
await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
|
|
75
77
|
monitor.removeSession(cmd.sessionId);
|
|
76
78
|
metrics.cleanupSession(cmd.sessionId);
|
|
77
79
|
break;
|
|
@@ -678,9 +680,11 @@ async function killSessionHandler(req, reply) {
|
|
|
678
680
|
return reply.status(404).send({ error: 'Session not found' });
|
|
679
681
|
}
|
|
680
682
|
try {
|
|
683
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
684
|
+
// reference a session that is still being destroyed.
|
|
685
|
+
await sessions.killSession(req.params.id);
|
|
681
686
|
eventBus.emitEnded(req.params.id, 'killed');
|
|
682
687
|
await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
|
|
683
|
-
await sessions.killSession(req.params.id);
|
|
684
688
|
monitor.removeSession(req.params.id);
|
|
685
689
|
metrics.cleanupSession(req.params.id);
|
|
686
690
|
return { ok: true };
|
|
@@ -997,14 +1001,16 @@ async function reapStaleSessions(maxAgeMs) {
|
|
|
997
1001
|
const ageMin = Math.round(age / 60000);
|
|
998
1002
|
console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
|
|
999
1003
|
try {
|
|
1004
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
1005
|
+
// reference a session that is still being destroyed.
|
|
1006
|
+
await sessions.killSession(session.id);
|
|
1007
|
+
eventBus.cleanupSession(session.id);
|
|
1000
1008
|
await channels.sessionEnded({
|
|
1001
1009
|
event: 'session.ended',
|
|
1002
1010
|
timestamp: new Date().toISOString(),
|
|
1003
1011
|
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1004
1012
|
detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
|
|
1005
1013
|
});
|
|
1006
|
-
eventBus.cleanupSession(session.id);
|
|
1007
|
-
await sessions.killSession(session.id);
|
|
1008
1014
|
monitor.removeSession(session.id);
|
|
1009
1015
|
metrics.cleanupSession(session.id);
|
|
1010
1016
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export declare class SessionManager {
|
|
|
65
65
|
private pendingQuestions;
|
|
66
66
|
private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
|
|
67
67
|
private parsedEntriesCache;
|
|
68
|
-
private sessionAcquireMutex;
|
|
68
|
+
private readonly sessionAcquireMutex;
|
|
69
69
|
constructor(tmux: TmuxManager, config: Config);
|
|
70
70
|
/** Validate that parsed data looks like a valid SessionState. */
|
|
71
71
|
private isValidState;
|
|
@@ -164,7 +164,7 @@ export declare class SessionManager {
|
|
|
164
164
|
* Returns the most recently active idle session, or null if none found.
|
|
165
165
|
* Used to resume existing sessions instead of creating duplicates.
|
|
166
166
|
* Issue #636: Verifies tmux window is still alive before returning.
|
|
167
|
-
* Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
|
|
167
|
+
* Issue #840/#880: Atomically acquires the session under a mutex to prevent TOCTOU race. */
|
|
168
168
|
findIdleSessionByWorkDir(workDir: string): Promise<SessionInfo | null>;
|
|
169
169
|
/** Release a session claim after the reuse path completes (success or failure). */
|
|
170
170
|
releaseSessionClaim(id: string): void;
|
package/dist/session.js
CHANGED
|
@@ -14,6 +14,7 @@ import { computeStallThreshold } from './config.js';
|
|
|
14
14
|
import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
|
|
15
15
|
import { persistedStateSchema, sessionMapSchema } from './validation.js';
|
|
16
16
|
import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
|
|
17
|
+
import { Mutex } from 'async-mutex';
|
|
17
18
|
/** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
|
|
18
19
|
function hydrateSessions(raw) {
|
|
19
20
|
const sessions = {};
|
|
@@ -62,8 +63,8 @@ export class SessionManager {
|
|
|
62
63
|
// #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
|
|
63
64
|
static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
|
|
64
65
|
parsedEntriesCache = new Map();
|
|
65
|
-
// Issue #840:
|
|
66
|
-
sessionAcquireMutex =
|
|
66
|
+
// Issue #840/#880: Explicit mutex to prevent TOCTOU races in session acquisition.
|
|
67
|
+
sessionAcquireMutex = new Mutex();
|
|
67
68
|
constructor(tmux, config) {
|
|
68
69
|
this.tmux = tmux;
|
|
69
70
|
this.config = config;
|
|
@@ -705,15 +706,9 @@ export class SessionManager {
|
|
|
705
706
|
* Returns the most recently active idle session, or null if none found.
|
|
706
707
|
* Used to resume existing sessions instead of creating duplicates.
|
|
707
708
|
* Issue #636: Verifies tmux window is still alive before returning.
|
|
708
|
-
* Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
|
|
709
|
+
* Issue #840/#880: Atomically acquires the session under a mutex to prevent TOCTOU race. */
|
|
709
710
|
async findIdleSessionByWorkDir(workDir) {
|
|
710
|
-
|
|
711
|
-
let release;
|
|
712
|
-
const lock = new Promise((resolve) => { release = resolve; });
|
|
713
|
-
const previous = this.sessionAcquireMutex;
|
|
714
|
-
this.sessionAcquireMutex = lock;
|
|
715
|
-
await previous.catch(() => { }); // tolerate prior rejection
|
|
716
|
-
try {
|
|
711
|
+
return this.sessionAcquireMutex.runExclusive(async () => {
|
|
717
712
|
const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
|
|
718
713
|
if (candidates.length === 0)
|
|
719
714
|
return null;
|
|
@@ -729,10 +724,7 @@ export class SessionManager {
|
|
|
729
724
|
}
|
|
730
725
|
}
|
|
731
726
|
return null;
|
|
732
|
-
}
|
|
733
|
-
finally {
|
|
734
|
-
release();
|
|
735
|
-
}
|
|
727
|
+
});
|
|
736
728
|
}
|
|
737
729
|
/** Release a session claim after the reuse path completes (success or failure). */
|
|
738
730
|
releaseSessionClaim(id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-bridge",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@fastify/static": "^9.0.0",
|
|
49
49
|
"@fastify/websocket": "^11.2.0",
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
51
|
+
"async-mutex": "^0.5.0",
|
|
51
52
|
"fastify": "^5.8.2",
|
|
52
53
|
"zod": "^4.3.6"
|
|
53
54
|
},
|