fix(auto): broaden worktree health check to all ecosystems (#1860)

* fix(auto): use PROJECT_FILES from detection.ts in worktree health check

The worktree health check introduced in #1833 hard-coded package.json
and src/ as the only valid project markers, blocking auto-mode dispatch
for Rust (Cargo.toml), Go (go.mod), Python (pyproject.toml), and 14
other ecosystems. Replace the JS-centric heuristic with the shared
PROJECT_FILES array from detection.ts which already covers 17+
ecosystems.

Fixes #1843

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update test assertion to match new project files message

The health check now says "no recognized project files" instead of
"no package.json or src/" after broadening to PROJECT_FILES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 17:22:34 -04:00 committed by GitHub
parent 358dc1da6b
commit b49cb8cbad
4 changed files with 190 additions and 9 deletions

View file

@ -26,6 +26,7 @@ import { runUnit } from "./run-unit.js";
import { debugLog } from "../debug-logger.js";
import { gsdRoot } from "../paths.js";
import { atomicWriteSync } from "../atomic-write.js";
import { PROJECT_FILES } from "../detection.js";
import { join } from "node:path";
// ─── generateMilestoneReport ──────────────────────────────────────────────────
@ -809,25 +810,27 @@ export async function runUnitPhase(
unitId,
});
// ── Worktree health check (#1833) ───────────────────────────────────
// ── Worktree health check (#1833, #1843) ────────────────────────────
// Verify the working directory is a valid git checkout with project
// files before dispatching work. A broken worktree causes agents to
// hallucinate summaries since they cannot read or write any files.
// Uses the shared PROJECT_FILES list from detection.ts to support all
// ecosystems (Rust, Go, Python, Java, etc.), not just JS.
if (s.basePath && unitType === "execute-task") {
const gitMarker = join(s.basePath, ".git");
const hasGit = deps.existsSync(gitMarker);
const hasPackageJson = deps.existsSync(join(s.basePath, "package.json"));
const hasSrcDir = deps.existsSync(join(s.basePath, "src"));
if (!hasGit) {
const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`;
debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit, hasPackageJson, hasSrcDir });
debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit });
ctx.ui.notify(msg, "error");
await deps.stopAuto(ctx, pi, msg);
return { action: "break", reason: "worktree-invalid" };
}
if (!hasPackageJson && !hasSrcDir) {
const msg = `Worktree health check failed: ${s.basePath} has no package.json or src/ — refusing to dispatch ${unitType} ${unitId}`;
debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit, hasPackageJson, hasSrcDir });
const hasProjectFile = PROJECT_FILES.some((f) => deps.existsSync(join(s.basePath, f)));
const hasSrcDir = deps.existsSync(join(s.basePath, "src"));
if (!hasProjectFile && !hasSrcDir) {
const msg = `Worktree health check failed: ${s.basePath} has no recognized project files — refusing to dispatch ${unitType} ${unitId}`;
debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasProjectFile, hasSrcDir });
ctx.ui.notify(msg, "error");
await deps.stopAuto(ctx, pi, msg);
return { action: "break", reason: "worktree-invalid" };

View file

@ -69,7 +69,7 @@ export interface ProjectSignals {
// ─── Project File Markers ───────────────────────────────────────────────────────
const PROJECT_FILES = [
export const PROJECT_FILES = [
"package.json",
"Cargo.toml",
"go.mod",

View file

@ -2122,7 +2122,7 @@ test("autoLoop stops when worktree has no project files for execute-task (#1833)
"should stop auto-mode when worktree has no project files",
);
const healthNotification = notifications.find(
(n) => n.includes("Worktree health check failed") && n.includes("no package.json or src/"),
(n) => n.includes("Worktree health check failed") && n.includes("no recognized project files"),
);
assert.ok(
healthNotification,

View file

@ -0,0 +1,178 @@
/**
* worktree-health-dispatch.test.ts Regression tests for the worktree health
* check in auto/phases.ts (#1833, #1843).
*
* Verifies that the pre-dispatch health check recognises non-JS project types
* (Rust, Go, Python, etc.) via the shared PROJECT_FILES list from detection.ts,
* rather than hard-coding package.json / src/ only.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { PROJECT_FILES } from "../detection.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Create a minimal git repo and return its path. */
function createGitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-"));
// All execSync calls use hardcoded strings only — no user input, no injection risk.
execSync("git init", { cwd: dir, stdio: "ignore" });
execSync("git config user.email test@test.com", { cwd: dir, stdio: "ignore" });
execSync("git config user.name Test", { cwd: dir, stdio: "ignore" });
writeFileSync(join(dir, "README.md"), "# test\n");
execSync("git add . && git commit -m init", { cwd: dir, stdio: "ignore" });
return dir;
}
/**
* Simulate the health check logic from auto/phases.ts.
*
* Returns true when the directory would PASS the health check (dispatch
* proceeds), false when it would FAIL (dispatch blocked).
*
* This mirrors the fixed logic: .git must exist, AND at least one
* PROJECT_FILES entry or a src/ directory must exist.
*/
function wouldPassHealthCheck(basePath: string, existsSyncFn: (p: string) => boolean): boolean {
const hasGit = existsSyncFn(join(basePath, ".git"));
if (!hasGit) return false;
for (const file of PROJECT_FILES) {
if (existsSyncFn(join(basePath, file))) return true;
}
if (existsSyncFn(join(basePath, "src"))) return true;
return false;
}
import { existsSync } from "node:fs";
// ─── Tests ───────────────────────────────────────────────────────────────────
test("PROJECT_FILES is exported and contains expected multi-ecosystem entries", () => {
assert.ok(Array.isArray(PROJECT_FILES), "PROJECT_FILES is an array");
assert.ok(PROJECT_FILES.length >= 17, `expected >= 17 entries, got ${PROJECT_FILES.length}`);
// Spot-check key ecosystems
assert.ok(PROJECT_FILES.includes("Cargo.toml"), "includes Rust marker");
assert.ok(PROJECT_FILES.includes("go.mod"), "includes Go marker");
assert.ok(PROJECT_FILES.includes("pyproject.toml"), "includes Python marker");
assert.ok(PROJECT_FILES.includes("package.json"), "includes JS marker");
assert.ok(PROJECT_FILES.includes("pom.xml"), "includes Java marker");
assert.ok(PROJECT_FILES.includes("Package.swift"), "includes Swift marker");
});
test("health check passes for Rust project (Cargo.toml, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n");
mkdirSync(join(dir, "crates"), { recursive: true });
assert.ok(wouldPassHealthCheck(dir, existsSync), "Rust project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for Go project (go.mod, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "go.mod"), "module example.com/test\n\ngo 1.21\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Go project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for Python project (pyproject.toml, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "pyproject.toml"), "[project]\nname = \"test\"\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Python project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for Java project (pom.xml, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "pom.xml"), "<project></project>\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Java project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for Swift project (Package.swift, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "Package.swift"), "// swift-tools-version:5.7\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Swift project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for C/C++ project (CMakeLists.txt, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "C/C++ project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for Elixir project (mix.exs, no package.json)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "mix.exs"), "defmodule Test.MixProject do\nend\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Elixir project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for JS project (package.json, backward compat)", () => {
const dir = createGitRepo();
try {
writeFileSync(join(dir, "package.json"), '{"name":"test"}\n');
assert.ok(wouldPassHealthCheck(dir, existsSync), "JS project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check passes for src/-only project (backward compat)", () => {
const dir = createGitRepo();
try {
mkdirSync(join(dir, "src"), { recursive: true });
assert.ok(wouldPassHealthCheck(dir, existsSync), "src/-only project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check fails for directory with no .git", () => {
const dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-nogit-"));
try {
writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n");
assert.ok(!wouldPassHealthCheck(dir, existsSync), "no-git directory should fail health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check fails for empty git repo with no project files", () => {
const dir = createGitRepo();
try {
assert.ok(!wouldPassHealthCheck(dir, existsSync), "empty git repo should fail health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});