fix(cases): bounded path probe landing fixes (#516)

- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
  cap (probeBeforeTouching), and whatever is still unknown is skipped. The
  per-spawn hook and statusLine helpers used to fall back to an unbounded
  lstat/readFile there, which on a dead workspace never settled and could take
  the last threadpool workers (and hang the boot hook sweep). New test: cap
  engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
  GET /api/cases/:name now say a folder was not checked (other mounts are still
  not answering) instead of blaming a healthy folder at the stall ceiling.
  errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
  probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
  OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
  .unreachable covers a refused probe, the boot sweep skips an unanswering
  workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
  (plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
  block, and the cap-below-ceiling test no longer depends on an inherited
  UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-05 20:00:30 +02:00
parent aca23aa404
commit 88f5a43a9f
17 changed files with 335 additions and 101 deletions
+27 -17
View File
@@ -59,17 +59,30 @@ async function pathExistsForWrite(path: string): Promise<boolean> {
}
}
/**
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
* stalled probe (the bulk cap refused it, or the stat failed with something other
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
* on a dead mount those never settle, and each would hold a threadpool worker the
* probe's ceiling does not count.
*/
async function probeBeforeTouching(path: string) {
const state = await probePath(path);
if (state !== 'unknown' || isNearStalledPath(path)) return state;
return probePath(path, { pastCap: true });
}
/**
* 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.
* it did not answer (a mount that is not responding, a refused probe, an unreadable
* path). See `probeBeforeTouching` for why "unknown" is a skip.
*/
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
const state = await probePath(path);
const state = await probeBeforeTouching(path);
if (state === 'absent') return 'absent';
if (state === 'unknown' && isNearStalledPath(path)) return 'unreachable';
if (state === 'unknown') return 'unreachable';
return false;
}
@@ -852,19 +865,16 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
const state = await probePath(workspace);
// "absent" stays absent: the install below would mkdir -p a deleted repo back
// into being. "unknown" is skipped too, never asked again with an unbounded
// lstat (see probeBeforeTouching).
const state = await probeBeforeTouching(workspace);
if (state === 'absent') return;
if (state === 'unknown') {
if (isNearStalledPath(workspace)) {
console.warn(
`[hooks] ${workspace} is not responding (unreachable mount?); Codeman hooks not checked or installed`
);
return;
}
// Any other "unknown" (the stall cap refused the probe, or the stat failed
// with something other than ENOENT) proves nothing about existence, and the
// install below would mkdir -p a deleted repo back into being: ask directly.
if (!(await pathExistsForWrite(workspace))) return;
console.warn(
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
);
return;
}
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
+3 -1
View File
@@ -159,7 +159,9 @@ export interface CaseInfo {
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.
* than "no such file"), or its probe was refused because folders on other unreachable
* mounts are still not answering, so whether it still exists is unknown. A refused
* probe can set this on a healthy linked case. Absent = it answered.
*/
unreachable?: boolean;
/**
+28 -1
View File
@@ -37,7 +37,9 @@
* 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. `pastCap` still stops at
* keep the cap; the per-spawn hook and statusLine helpers retry one refused
* probe past it and then skip a path that still answers "unknown", rather than
* touch it with an unbounded call. `pastCap` still stops at
* `PATH_PROBE_STALL_CEILING` (the threadpool size minus one), so explicit
* requests against several dead paths can never take the last worker.
*
@@ -203,6 +205,31 @@ export async function probePathKind(path: string, options: PathProbeOptions = {}
}
}
/**
* Why a probe of `path` answers "unknown" right now: its mount is not answering
* (`'stalled'`, it is near a stalled probe), new probes are refused because enough
* UNRELATED paths are stalled (`'refused'`; `pastCap` picks which limit applies), or
* neither, so the filesystem answered with an error such as EACCES or EIO
* (`'unreadable'`). For messages only: it reads the state now, not at probe time.
*/
export function unknownPathReason(path: string, options: PathProbeOptions = {}): 'stalled' | 'refused' | 'unreadable' {
if (isNearStalledPath(path)) return 'stalled';
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) return 'refused';
return 'unreadable';
}
/**
* User-facing sentence for an "unknown" probe of `path` (`label` names it, e.g.
* "workingDir"). A refused probe says so, rather than blaming a folder that was never
* checked: at the ceiling every new folder reads "unknown" until a dead mount answers.
*/
export function describeUnknownPath(label: string, path: string, options: PathProbeOptions = {}): string {
return unknownPathReason(path, options) === 'refused'
? `${label} was not checked: folders on other unreachable mounts are still not answering, ` +
`so Codeman is not checking new folders until one does (see the server log): ${path}`
: `${label} is not responding or not readable: ${path}`;
}
/** 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);
+8 -1
View File
@@ -68,5 +68,12 @@ 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 {
boundedPathExists,
describeUnknownPath,
probePath,
probePathKind,
isNearStalledPath,
unknownPathReason,
} from './bounded-path-probe.js';
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
+17 -1
View File
@@ -28,6 +28,7 @@
import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { isValidWorkingDir } from './schemas.js';
import { describeUnknownPath, probePath } from '../utils/index.js';
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
@@ -61,7 +62,7 @@ export interface NewCasePathContext {
export type NewCasePathResult =
| { ok: true; path: string; existedEmpty: boolean }
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS'; reason: string };
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE'; reason: string };
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
@@ -145,6 +146,21 @@ export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext):
const typedBlock = blockedReason(target, ctx);
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
// Bounded first: the parent can sit on a network mount that stopped answering, where the
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
const parentState = await probePath(dirname(target), { pastCap: true });
if (parentState === 'absent') {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
if (parentState === 'unknown') {
return {
ok: false,
code: 'UNREACHABLE',
reason: describeUnknownPath('The parent folder', dirname(target), { pastCap: true }),
};
}
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent: string;
try {
+8 -2
View File
@@ -51,7 +51,7 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { boundedPathExists, probePath } from '../../utils/index.js';
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -464,6 +464,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
// POST /api/sessions for a workingDir on a dead mount.
if (prepared.code === 'UNREACHABLE') {
reply.code(422);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
}
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
const code =
@@ -1752,7 +1758,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!linked) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Case folder is not responding or not readable: ${casePath}`
describeUnknownPath('Case folder', casePath, { pastCap: true })
);
}
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
+3 -3
View File
@@ -174,7 +174,7 @@ import {
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
import { probePathKind } from '../../utils/index.js';
import { describeUnknownPath, 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';
@@ -980,7 +980,7 @@ export function registerSessionRoutes(
if (kind === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`workingDir is not responding or not readable: ${workingDir}`
describeUnknownPath('workingDir', workingDir, { pastCap: true })
);
}
if (kind === 'absent') {
@@ -3710,7 +3710,7 @@ export function registerSessionRoutes(
if (localCaseState === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Case folder is not responding or not readable: ${resolvedCasePath}`
describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true })
);
}