From 04eb1ed1583e8245ca1797b1503556486dec933d Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:46:18 +0200 Subject: [PATCH] feat(limits): make the global session cap configurable with CODEMAN_MAX_SESSIONS The cap of 50 concurrent sessions was a hard-coded constant, so a machine with the memory for more (or a small box that wants fewer) had to patch the source. CODEMAN_MAX_SESSIONS now sets it at startup. The default stays 50, and anything that is not a positive integer (empty, 0, negative, fractional, 1e3, out of safe-integer range) falls back to it, so a typo never locks session creation. Every site keeps reading MAX_CONCURRENT_SESSIONS, so the session routes, cron launches, the scheduled-run loop and the multi-user per-user cap (half the global cap unless CODEMAN_MAX_SESSIONS_PER_USER is set) all follow the variable. The test goes through sessionCapacityState and sessionCapacityMessage, the check every session-creating route uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/max-sessions-env.md | 5 ++ docs/cron-guide.md | 4 +- .../skills/codeman/reference/endpoints.md | 4 +- skills/codeman/reference/endpoints.md | 4 +- src/config/map-limits.ts | 18 +++- src/web/schemas.ts | 2 +- test/config/max-sessions.test.ts | 83 +++++++++++++++++++ 7 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 .changeset/max-sessions-env.md create mode 100644 test/config/max-sessions.test.ts diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md new file mode 100644 index 00000000..ff2516d6 --- /dev/null +++ b/.changeset/max-sessions-env.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (a positive integer, read at startup), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..bc08c52d 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -261,8 +261,8 @@ Sequence in `CronService.launch()`: 4. **Auto-close previous session** (recurring schedules, unless `autoClosePreviousSession: false`): any still-open session created by this job's previous runs is closed via the normal session-cleanup path. -5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap → - **`failed`**. +5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS`, 50 unless + `CODEMAN_MAX_SESSIONS` sets it). At cap → **`failed`**. 6. A `Session` is created **with `useMux: true`** (so it runs inside tmux), registered, listeners attached, and started via `startInteractive()` (`startShell()` for `shell` mode). Model/claudeMode come from global config. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f2ef2fb2..2799f542 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f2ef2fb2..2799f542 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index c3e246c2..0d27a2f6 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -18,11 +18,27 @@ // Session Tracking Limits // ============================================================================ +/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */ +const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; + +/** + * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer). + * Anything else (unset, empty, 0, negative, fractional, not a number) falls back + * to {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + */ +function resolveMaxConcurrentSessions(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.CODEMAN_MAX_SESSIONS?.trim(); + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS; + const n = Number(raw); + return Number.isSafeInteger(n) && n >= 1 ? n : DEFAULT_MAX_CONCURRENT_SESSIONS; +} + /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise; read once at startup. */ -export const MAX_CONCURRENT_SESSIONS = 50; +export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions(); // ============================================================================ // SSE Client Limits diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..196e14c3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,7 @@ export const CaseOrderSchema = z.object({ /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only - // its open-tab order (max sessions is 50) — 500 leaves ample headroom while + // its open-tab order (max sessions is 50 by default) — 500 leaves ample headroom while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), }); diff --git a/test/config/max-sessions.test.ts b/test/config/max-sessions.test.ts new file mode 100644 index 00000000..58f46e26 --- /dev/null +++ b/test/config/max-sessions.test.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`. + * + * Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every + * session-creating route goes through, so a regression in the env parsing OR in + * the wiring from `map-limits` to the routes (and to the derived per-user cap in + * multi-user mode) fails here. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type Helpers = typeof import('../../src/web/route-helpers.js'); + +/** A sessions map of the given size, every session owned by `owner`. */ +function sessionsOf(n: number, owner?: string): ReadonlyMap { + return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never])); +} + +/** Fresh module graph, so map-limits re-reads the environment at import. */ +async function loadWithEnv(env: Record): Promise { + vi.resetModules(); + vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined as unknown as string); + vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined as unknown as string); + vi.stubEnv('CODEMAN_MULTIUSER', undefined as unknown as string); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value as string); + return import('../../src/web/route-helpers.js'); +} + +describe('global session cap (CODEMAN_MAX_SESSIONS)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('stays at 50 when the variable is not set', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({}); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)'); + }); + + it('raises the cap', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' }); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)'); + }); + + it('lowers the cap, ignoring surrounding whitespace', async () => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' }); + expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true); + }); + + it.each(['', '0', '-5', '2.5', 'abc', '1e3', '99999999999999999999'])( + 'falls back to 50 for an unusable value %j', + async (value) => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + } + ); + + it('derives the multi-user per-user cap (half the global cap) from it', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ + CODEMAN_MAX_SESSIONS: '100', + CODEMAN_MULTIUSER: '1', + }); + expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true }); + expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)'); + }); + + it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => { + const { sessionCapacityState } = await loadWithEnv({ + CODEMAN_MAX_SESSIONS: '100', + CODEMAN_MAX_SESSIONS_PER_USER: '10', + CODEMAN_MULTIUSER: '1', + }); + expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false); + expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true); + }); +});