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
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });