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.
@@ -61,6 +61,7 @@ export interface PipelineState {
61
61
  export declare class PipelineManager {
62
62
  private sessions;
63
63
  private eventBus?;
64
+ private static readonly PIPELINE_RETRY_MAX_ATTEMPTS;
64
65
  private pipelines;
65
66
  private pipelineConfigs;
66
67
  private pollInterval;
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';
@@ -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
- await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
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: Mutex to prevent TOCTOU race in findIdleSessionByWorkDir
66
- sessionAcquireMutex = Promise.resolve();
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
- // Issue #840: Acquire mutex — chain onto the previous operation
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.4",
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
  },