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
+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 });