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
+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 {