diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md new file mode 100644 index 00000000..5691f2c2 --- /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` (1 to 462, read at startup; a larger value is clamped to 462), 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/architecture-invariants.md b/docs/architecture-invariants.md index 2cca18e9..76d8b51e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 462), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..461b5cb7 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. @@ -284,7 +284,7 @@ automatically after **Run Now**. > ⚠️ **Session-cap math if you disable auto-close.** With > `autoClosePreviousSession: false`, nothing ever closes the sessions a > recurring job creates — an interval job every 30 min creates 48 tabs/day and -> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after +> hits the global session cap (50 by default, `CODEMAN_MAX_SESSIONS`) in ~25 hours (sooner with existing tabs), after > which **every** fire of **every** job fails with "Maximum concurrent sessions > reached" until you delete tabs by hand. Leave auto-close on for unattended > recurring jobs, or clean up sessions yourself. @@ -376,7 +376,7 @@ boot. Sessions the jobs create persist through the normal session-recovery path. | Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` | | Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) | | Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) | -| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` | +| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) | | Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) | | `name` length | 1–200 | `CronJobSchema` | | `promptText` length | ≤ 100000 | `CronJobSchema` | diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 0f940586..7f72223e 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -185,7 +185,7 @@ curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: applicati | Limit | Default | | --------------------- | ------------------------------------------ | -| Max sessions | 50 | +| Max sessions | 50 (`CODEMAN_MAX_SESSIONS`) | | Max agent windows | 500 | | Max SSE clients | 100 | | Terminal buffer | 32 MB per session | diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce..13b71aeb 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -221,6 +221,7 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | | `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index a9772f77..d1376225 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -666,8 +666,9 @@ You are yourself a session on this server, and the API has **no undo**. user's repository state, so say that you did; **removing** one discards any uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)). - Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface. -- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user - cap of 25 that fires the same 409). Case creation is uncapped and writes real +- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and, + in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires + the same 409. Case creation is uncapped and writes real directories. Clean up every session you start, and never retry `quick-start` in a loop. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f2ef2fb2..2dafe613 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 cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`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 | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ 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, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | 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` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 2dfe8bb0..c0020676 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one: - hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns `SESSION_BUSY` for the identical condition. -`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the -per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a +`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via +`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap, +25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a remote or docker host named by the case no longer exists), `OPERATION_FAILED` and `INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on `.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r` diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index a9772f77..d1376225 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -666,8 +666,9 @@ You are yourself a session on this server, and the API has **no undo**. user's repository state, so say that you did; **removing** one discards any uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)). - Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface. -- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user - cap of 25 that fires the same 409). Case creation is uncapped and writes real +- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and, + in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires + the same 409. Case creation is uncapped and writes real directories. Clean up every session you start, and never retry `quick-start` in a loop. diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f2ef2fb2..2dafe613 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 cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`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 | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ 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, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | 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` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 2dfe8bb0..c0020676 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one: - hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns `SESSION_BUSY` for the identical condition. -`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the -per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a +`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via +`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap, +25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a remote or docker host named by the case no longer exists), `OPERATION_FAILED` and `INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on `.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r` diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index c3e246c2..f7b0f010 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -18,11 +18,40 @@ // Session Tracking Limits // ============================================================================ +/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */ +const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; + +/** + * Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. An owner's tab layout + * holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well + * as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs. + * 512 - 50 = 462 keeps session creation answering with the capacity message + * instead of failing with a 500 on the layout limit, at the default `MAX_WEBVIEWS` + * and with no pinned closed sessions: a pinned session stays in the layout as a + * stopped record after it is closed, and it shares the same 512-ref budget. + * (`PUT /api/session-order` accepts up to 500 ids, which stays above this.) + */ +export const MAX_CONCURRENT_SESSIONS_CEILING = 462; + +/** + * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer, + * clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). 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(): number { + const raw = process.env.CODEMAN_MAX_SESSIONS?.trim(); + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS; + const n = Number(raw); + return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS; +} + /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 462); 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..0c12f797 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,8 @@ 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, at most 462 via CODEMAN_MAX_SESSIONS, + // see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room 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..03440505 --- /dev/null +++ b/test/config/max-sessions.test.ts @@ -0,0 +1,95 @@ +/** + * @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); + vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined); + vi.stubEnv('CODEMAN_MULTIUSER', undefined); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + 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(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)'); + }); + + it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => { + vi.resetModules(); + const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js'); + const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js'); + const { MAX_TAB_REFS } = await import('../../src/tab-layout.js'); + expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS); + }); + + it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('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); + }); +});