fix(cases): tell an unreachable path from an absent one, scope the stall cap

The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
This commit is contained in:
Aamer Akhter
2026-10-04 20:30:40 -04:00
parent 00b935abe6
commit d1bfbb4fcf
15 changed files with 1028 additions and 126 deletions
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
*
* A linked case can live on a network mount, and a hard mount that went away makes
* `stat()` wait until the mount comes back. The probe gives up on such a path after
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
*
* Both are env-overridable, in the same style as the other config modules. A slow
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
* a longer timeout; a server started with a larger `UV_THREADPOOL_SIZE` can afford a
* higher stall cap.
*
* @module config/path-probe
*/
function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
/** How long a caller waits for one path probe before the answer is "unknown". */
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
/**
* Timed-out probes allowed to stay pending before new probes are refused (answered
* "unknown" without a stat). This is a backstop, not the main defence: a stalled
* path already takes its neighbours (same parent directory) out of probing, so the
* cap only engages once three UNRELATED places have stopped answering. The default
* leaves one of libuv's default four workers free for the rest of the process.
*/
export const MAX_STALLED_PATH_PROBES = envInt('CODEMAN_PATH_PROBE_MAX_STALLED', 3, 1, 64);
+42 -10
View File
@@ -39,12 +39,12 @@ import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { boundedPathExists } from './utils/bounded-path-probe.js';
import { isNearStalledPath, probePath } from './utils/index.js';
/**
* Existence check for a WRITER. Unlike `boundedPathExists`, which answers
* "absent" for a path it could not reach in time, this tells "missing" apart
* from "unreachable": only ENOENT reads as absent, anything else throws, so a
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
* which gives up after a timeout and answers "unknown", this waits for the real
* answer: only ENOENT reads as absent, anything else throws, so a
* stalled or unreadable workspace can never be mistaken for an empty one and
* have its settings recreated over the top. It is async, so a dead mount ties
* up a threadpool worker rather than the event loop.
@@ -59,6 +59,20 @@ async function pathExistsForWrite(path: string): Promise<boolean> {
}
}
/**
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
* it sits on a mount that is not answering (near a stalled probe). An "unknown"
* that is NOT near a stalled probe (the probe was refused for capacity, or the stat
* failed with something other than ENOENT) is not a reason to skip: the caller goes
* on, and its own async read or write settles the question for that one path.
*/
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
const state = await probePath(path);
if (state === 'absent') return 'absent';
if (state === 'unknown' && isNearStalledPath(path)) return 'unreachable';
return false;
}
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
* writer in this module (hooks, env, model, statusLine) shares this map, so
@@ -576,7 +590,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
if (!(await boundedPathExists(settingsPath))) return;
if (!(await pathExistsForWrite(settingsPath))) return;
let existing: Record<string, unknown>;
try {
@@ -756,7 +770,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
if (!(await boundedPathExists(join(casePath, '.claude', 'settings.local.json')))) return;
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
let existing: Record<string, unknown>;
try {
@@ -838,7 +852,13 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
if (!(await boundedPathExists(workspace))) return;
const skip = await absentOrUnreachable(workspace);
if (skip === 'unreachable') {
console.warn(
`[hooks] ${workspace} is not responding (unreachable mount?); Codeman hooks not checked or installed`
);
}
if (skip) return;
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
} catch {
@@ -975,7 +995,7 @@ function statusLineExporterScriptContent(): string {
}
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
if (!(await boundedPathExists(settingsPath))) return undefined;
if (await absentOrUnreachable(settingsPath)) return undefined;
try {
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
const current = parsed.statusLine as { command?: unknown } | undefined;
@@ -1121,9 +1141,21 @@ export async function resolveStatusLineCliCommand(
): Promise<string | undefined> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
let userHasOwnStatusLine = false;
if (await boundedPathExists(settingsPath)) {
const skip = await absentOrUnreachable(settingsPath);
// Unreachable: whether the user configured their own statusLine there cannot be
// told, and this must never override a real one, so inject nothing.
if (skip === 'unreachable') return undefined;
if (!skip) {
let raw: string;
try {
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
raw = await readFile(settingsPath, 'utf-8');
} catch (err) {
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
raw = '';
}
try {
const existing = raw ? JSON.parse(raw) : {};
const current = existing.statusLine as { command?: unknown } | undefined;
if (current && typeof current.command === 'string') {
if (current.command.includes(STATUSLINE_MARKER)) {
+5
View File
@@ -157,6 +157,11 @@ export interface CaseInfo {
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/**
* The case folder did not answer (an unreachable network mount, or an error other
* than "no such file"), so whether it still exists is unknown. Absent = it answered.
*/
unreachable?: boolean;
/**
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
* (the packaged skill's workers, or any spawn naming a parent session), read back
+150 -42
View File
@@ -8,59 +8,145 @@
* one of libuv's few threadpool workers, which every other `fs`, `dns.lookup`
* and `crypto` call in the process shares.
*
* `boundedPathExists()` therefore:
* - probes asynchronously and answers `false` after `PROBE_TIMEOUT_MS`, so a
* request never waits on a dead mount for longer than that;
* - shares one in-flight probe per path, and keeps answering `false` for a path
* whose probe timed out until that probe finally settles (so a dead path is
* not re-probed on every request, and is re-probed once the mount recovers);
* - stops starting new probes once `MAX_STALLED_PROBES` timed-out probes are
* still pending, so stalled stats cannot drain the threadpool. Probes that are
* merely in flight do not count, so concurrent healthy probes never get a
* false negative.
* The probe therefore answers one of THREE things, never two:
* - `'present'` / `'absent'`: the filesystem answered (ENOENT and ENOTDIR are
* the only errors that mean absent);
* - `'unknown'`: it did not answer in `PATH_PROBE_TIMEOUT_MS`, it answered with
* some other error (EIO from a soft mount that gave up, EACCES), or the probe
* was refused (below). "Unknown" is NOT "absent": a caller that would create,
* scaffold or 404 on absence must not do so on unknown.
*
* Like `existsSync`, it follows symlinks and reports any error as "absent". It
* is meant for READ decisions (is it there, show it or not). A writer that must
* tell "missing" apart from "unreachable" should not treat its `false` as
* permission to create or overwrite anything.
* And it keeps a dead mount from draining the threadpool:
* - one in-flight probe per path, shared by concurrent callers;
* - a path whose probe timed out is "stalled" until that stat finally settles.
* Paths NEAR a stalled one are answered "unknown" without a new stat, so one
* dead mount costs one worker, not one per case and file on it. "Near" means on
* the same mount: under the deepest mount point holding the stalled path, read
* from `/proc/self/mounts` (procfs, which never waits on the dead filesystem).
* Where that table is unavailable (not Linux), or the deepest mount is `/`, it
* narrows to the stalled path and everything under it. Unrelated paths are
* probed normally;
* - once `MAX_STALLED_PATH_PROBES` stalled stats are pending, new probes are
* refused process-wide (answered "unknown"), since each would risk another
* worker. Probes merely in flight do not count, so concurrent healthy probes
* never get refused. A caller acting on ONE path at a user's explicit request
* (opening a case, starting a session in it) may pass `{ pastCap: true }`: its
* probe is still bounded and still recorded as stalled if it hangs (so a dead
* path costs at most one worker however often it is retried), but it is not
* refused just because unrelated mounts are dead. Bulk scans (the case list)
* and per-spawn helpers keep the cap.
*
* Both events are logged once (`console.warn`): a path's first stall, and the
* cap engaging, so "my case vanished" and "hooks stopped firing" leave a trace.
*
* Writers should not use this at all: a writer that must tell "missing" apart
* from "unreachable" wants an ENOENT-aware async `lstat` (see
* `pathExistsForWrite` in hooks-config.ts).
*
* @module utils/bounded-path-probe
*/
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_TIMEOUT_MS } from '../config/path-probe.js';
/** How long a caller waits for one probe before treating the path as absent. */
export const PROBE_TIMEOUT_MS = 1_500;
/** Timed-out probes allowed to remain pending before new probes are refused. */
export const MAX_STALLED_PROBES = 2;
/** What a probe could establish about a path. */
export type PathProbeState = 'present' | 'absent' | 'unknown';
/** Like {@link PathProbeState}, with "present" split by whether it is a directory. */
export type PathProbeKind = 'directory' | 'file' | 'absent' | 'unknown';
const inFlight = new Map<string, Promise<boolean>>();
const stalled = new Set<string>();
const inFlight = new Map<string, Promise<PathProbeKind>>();
/** Stalled path -> the directory whose subtree is answered "unknown" while it stays stalled. */
const stalled = new Map<string, string>();
let capWarned = false;
async function statExists(path: string): Promise<boolean> {
async function statKind(path: string): Promise<PathProbeKind> {
try {
await fs.stat(path);
return true;
} catch {
return false;
return (await fs.stat(path)).isDirectory() ? 'directory' : 'file';
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown';
}
}
/**
* Resolve whether `path` exists without letting an unresponsive filesystem
* block the caller for longer than `PROBE_TIMEOUT_MS`.
*/
export async function boundedPathExists(path: string): Promise<boolean> {
if (stalled.has(path)) return false;
function isWithin(path: string, root: string): boolean {
if (path === root) return true;
return path.startsWith(root.endsWith(sep) ? root : root + sep);
}
let probe = inFlight.get(path);
/** Deepest mount point holding `abs`, from the kernel's mount table; undefined when unreadable. */
function mountPointOf(abs: string): string | undefined {
let table: string;
try {
table = readFileSync('/proc/self/mounts', 'utf-8');
} catch {
return undefined;
}
let best: string | undefined;
for (const line of table.split('\n')) {
const field = line.split(' ')[1];
if (!field) continue;
// The table octal-escapes space, tab, newline and backslash in mount points.
const mountPoint = field.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)));
if (isWithin(abs, mountPoint) && (!best || mountPoint.length > best.length)) best = mountPoint;
}
return best;
}
/** The subtree a stalled path takes down with it: its mount, else just itself (see the module comment). */
function stallScope(abs: string): string {
const mountPoint = mountPointOf(abs);
return mountPoint && mountPoint !== '/' ? mountPoint : abs;
}
/**
* Whether `path` is near a path whose probe is still stalled (see the module
* comment), i.e. whether the probe would answer "unknown" for it without a stat.
* Lets a caller tell "this workspace sits on the dead mount" apart from "the
* probe was refused for capacity".
*/
export function isNearStalledPath(path: string): boolean {
const abs = resolve(path);
for (const scope of stalled.values()) {
if (isWithin(abs, scope)) return true;
}
return false;
}
/** Options for {@link probePathKind} / {@link probePath}. */
export interface PathProbeOptions {
/** Probe even while the stall cap is engaged (see the module comment). */
pastCap?: boolean;
}
/**
* Probe `path` without letting an unresponsive filesystem block the caller for
* longer than `PATH_PROBE_TIMEOUT_MS`. Follows symlinks, like `stat()`.
*/
export async function probePathKind(path: string, options: PathProbeOptions = {}): Promise<PathProbeKind> {
const abs = resolve(path);
if (isNearStalledPath(abs)) return 'unknown';
let probe = inFlight.get(abs);
if (!probe) {
if (stalled.size >= MAX_STALLED_PROBES) return false;
probe = statExists(path);
inFlight.set(path, probe);
void probe.finally(() => {
inFlight.delete(path);
stalled.delete(path);
if (stalled.size >= MAX_STALLED_PATH_PROBES && !options.pastCap) {
if (!capWarned) {
capWarned = true;
console.warn(
`[path-probe] ${stalled.size} path probes are stalled on unresponsive filesystems; ` +
'not starting new ones until one answers (paths read as unknown meanwhile)'
);
}
return 'unknown';
}
probe = statKind(abs);
const started = probe;
inFlight.set(abs, started);
void started.finally(() => {
inFlight.delete(abs);
stalled.delete(abs);
if (stalled.size < MAX_STALLED_PATH_PROBES) capWarned = false;
});
}
@@ -68,11 +154,17 @@ export async function boundedPathExists(path: string): Promise<boolean> {
try {
return await Promise.race([
probe,
new Promise<boolean>((resolve) => {
new Promise<PathProbeKind>((resolveTimeout) => {
timer = setTimeout(() => {
if (inFlight.get(path) === probe) stalled.add(path);
resolve(false);
}, PROBE_TIMEOUT_MS);
if (inFlight.get(abs) === probe && !stalled.has(abs)) {
stalled.set(abs, stallScope(abs));
console.warn(
`[path-probe] ${abs} did not answer within ${PATH_PROBE_TIMEOUT_MS} ms ` +
'(unreachable mount?); treating it and its neighbours as unknown until it does'
);
}
resolveTimeout('unknown');
}, PATH_PROBE_TIMEOUT_MS);
timer.unref?.();
}),
]);
@@ -80,3 +172,19 @@ export async function boundedPathExists(path: string): Promise<boolean> {
if (timer) clearTimeout(timer);
}
}
/** Tri-state probe of `path`; see the module comment for what "unknown" means. */
export async function probePath(path: string, options: PathProbeOptions = {}): Promise<PathProbeState> {
const kind = await probePathKind(path, options);
return kind === 'directory' || kind === 'file' ? 'present' : kind;
}
/**
* `true` only when `path` is known to exist. For DISPLAY decisions only (does a
* case have a CLAUDE.md): it folds "unknown" into `false`, so never use it to
* decide that something is absent and may be created, scaffolded or reported
* missing; use {@link probePath} for that.
*/
export async function boundedPathExists(path: string): Promise<boolean> {
return (await probePath(path)) === 'present';
}
+2
View File
@@ -68,3 +68,5 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
export { boundedPathExists, probePath, probePathKind, isNearStalledPath } from './bounded-path-probe.js';
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
+12 -4
View File
@@ -1874,10 +1874,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2084,10 +2088,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+42 -19
View File
@@ -50,7 +50,7 @@ import {
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { boundedPathExists } from '../../utils/bounded-path-probe.js';
import { boundedPathExists, probePath } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -163,11 +163,11 @@ function gitDiagnosticLine(stderr: string): string {
* hooks, which run on the user's machine when a session starts in the case, so
* the clone response says so out loud instead of silently merging into them.
*/
async function repoShipsClaudeSettings(casePath: string): Promise<boolean> {
for (const file of ['settings.json', 'settings.local.json']) {
if (await boundedPathExists(join(casePath, '.claude', file))) return true;
}
return false;
function repoShipsClaudeSettings(casePath: string): boolean {
// Deliberately NOT the bounded path probe: the tree was just cloned into the
// local case space (and lstat'ed synchronously moments ago), so a bound protects
// nothing here, while a probe answering "unknown" could silently drop this warning.
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
/**
@@ -285,15 +285,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const existingNames = new Set(cases.map((c) => c.name));
if (admin) {
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && (await boundedPathExists(path))) {
cases.push({
name,
path,
hasClaudeMd: await boundedPathExists(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
}
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
const state = await probePath(path);
if (state === 'absent') continue;
// An unreachable linked case (a dead network mount) stays listed and says
// so: dropping it would read as "deleted" and invite a same-name local case.
cases.push({
name,
path,
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
linked: true,
location: 'linked-local',
...(state === 'unknown' ? { unreachable: true } : {}),
});
}
}
@@ -619,7 +623,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
} else {
warnings.push('Kept the repository’s own CLAUDE.md.');
}
if (await repoShipsClaudeSettings(casePath)) {
if (repoShipsClaudeSettings(casePath)) {
warnings.push(
'This repository ships its own .claude/settings files. Codeman merged its hooks alongside them without removing anything — review them before starting a session, since repo-supplied hooks run on this machine.'
);
@@ -1637,12 +1641,27 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
const casePath = await resolveCasePath(name, getAuthUser(req));
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
if (!(await boundedPathExists(casePath))) {
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
// a path that merely did not answer (a dead network mount) must never get it.
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
const state = await probePath(casePath, { pastCap: true });
if (state === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
if (state === 'unknown') {
// The linked registry knows where the case lives, so say where, and that
// it is not answering. A local case has no such record to fall back on.
if (!linked) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Case folder is not responding or not readable: ${casePath}`
);
}
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
}
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
return {
name,
path: casePath,
@@ -1664,7 +1683,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!(await boundedPathExists(fixPlanPath))) {
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
if (fixPlanState === 'unknown') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
}
if (fixPlanState === 'absent') {
return { exists: false, content: null, todos: [] };
}
+28 -8
View File
@@ -174,6 +174,7 @@ import {
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
import { probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
@@ -971,16 +972,23 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
// Validate workingDir exists and is a directory
// Validate workingDir exists and is a directory. Bounded: a workingDir on a
// network mount that stopped answering must not freeze the event loop, and
// "did not answer" is reported as such, never as "does not exist".
if (body.workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
const kind = await probePathKind(workingDir, { pastCap: true });
if (kind === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`workingDir is not responding or not readable: ${workingDir}`
);
}
if (kind === 'absent') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
if (kind !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
}
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
@@ -3694,9 +3702,21 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
}
// Bounded probe of a local case folder: a linked case can sit on a network mount
// that stopped answering, and a synchronous check there froze the whole server.
// Only a DEFINITE absence may scaffold a new case; "did not answer" must not
// create one over the top of where the real case is mounted.
const localCaseState = remote || docker ? undefined : await probePathKind(resolvedCasePath, { pastCap: true });
if (localCaseState === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Case folder is not responding or not readable: ${resolvedCasePath}`
);
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (!remote && !docker && !existsSync(resolvedCasePath)) {
if (localCaseState === 'absent') {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });