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
+34 -11
View File
@@ -22,18 +22,26 @@ const probe = vi.hoisted(() => {
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
for (const dead of probe.dead) {
if (String(path) === dead || String(path).startsWith(dead + '/')) {
return new Promise((resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
void resolve;
});
/** A call on a dead path never settles (a hard mount), until afterEach releases it. */
const hangOnDead = <F extends (...a: never[]) => unknown>(real: F): F =>
((path: string, ...rest: unknown[]) => {
for (const dead of probe.dead) {
if (String(path) === dead || String(path).startsWith(dead + '/')) {
return new Promise((resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
void resolve;
});
}
}
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const hung = {
stat: hangOnDead(actual.stat),
lstat: hangOnDead(actual.lstat),
readFile: hangOnDead(actual.readFile),
realpath: hangOnDead(actual.realpath),
};
return { ...actual, ...hung, default: { ...actual, ...hung } };
});
import { applyWorkspaceHooks, resolveStatusLineCliCommand, stripCaseEnvKeys } from '../src/hooks-config.js';
@@ -149,6 +157,21 @@ describe('workspace helpers while other mounts are unreachable', () => {
expect(await resolveStatusLineCliCommand(workspace, true)).toMatch(/statusline-exporter\.sh$/);
});
it('skips, within the probe timeout, dead workspaces whose probes the bulk cap refused', async () => {
// Two unrelated stalls engage the bulk cap, so probes of these two paths are
// refused without a stat. Their lstat and readFile hang too: a helper that
// touched them directly would never return and would hold a threadpool worker.
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
probe.dead.add('/mnt/dead-nas-c');
probe.dead.add('/mnt/dead-nas-d');
const within = <T>(work: Promise<T>) =>
Promise.race([work, new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000))]);
expect(await within(applyWorkspaceHooks('/mnt/dead-nas-c/project', true))).toBeUndefined();
expect(await within(resolveStatusLineCliCommand('/mnt/dead-nas-d/project', true))).toBeUndefined();
expect(existsSync('/mnt/dead-nas-c/project')).toBe(false);
});
it('does not inject the exporter into a workspace on the dead mount', async () => {
probe.dead.add('/mnt/dead-nas-y');
expect(await probePath('/mnt/dead-nas-y/project')).toBe('unknown');