Files
pygienium/tests/check-runner.test.ts
Michael Freno 8768f9de97
Some checks failed
port-to-omp / port (push) Failing after 2s
fix: sub-agent hang/crash stranding runs mid-phase with no save
A stalled sub-agent (provider stream never settling after the final tool
call, or a throwing session-event listener recursing through the SDK's
run-failure path to stack overflow) left the phase stuck in_progress with
no state save, no error, and no completion message — observed twice in
freno-dev, both times after comments wrote findings.md.

- agent-runner: 60-min settle watchdog on every agent phase
  (PYGIENIUM_AGENT_TIMEOUT_MS, env-tunable); timeout disposes the session
  and fails the phase loudly with a /pygienium-resume hint instead of
  hanging the check-runner's await forever.
- agent-runner: applySessionEvent — the session.subscribe listener can no
  longer throw into the SDK event pipeline (guards for partial/malformed
  events, optional-chained message_update, throwing chat forwarder).
- comments: scan no longer regenerates the full report as its final
  message (findings.md is the source of truth); fix phase reads
  findings.md with embedded fallback.
- check-runner: failing state-save inside the catch path can't double-
  fault or escape as an unhandled rejection; completion posting is
  best-effort.
- tests: watchdog timeout test, comments task-text regression tests,
  applySessionEvent malformed-shape unit tests. 146 pass, tsc clean.
- README: document PYGIENIUM_AGENT_TIMEOUT_MS.
2026-08-11 08:47:12 -04:00

159 lines
5.3 KiB
TypeScript

/**
* check-runner.test.ts — integration test for the orchestration keystone.
*
* Registers a throwaway "smoke" check whose fake sub-agent writes a marker
* file, then invokes the per-check command handler with a stub context and
* asserts the run-state marks the check complete and the marker exists.
* Also asserts `--fix` runs the fix phase and records changes, while
* scan-only does not.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearChecks,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import {
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
AGENT_TIMEOUT_ENV,
type AgentRunResult,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState, runStatePath } from "../src/run-state.js";
function smokeCheck(): CheckDefinition {
return {
name: "smoke",
label: "Smoke",
description: "Throwaway smoke check for tests",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "scan",
buildScanTask: (_cwd, scope) =>
`!write .pygienium/smoke.marker smoke-complete\n!echo smoke-findings for ${scope.target}`,
buildFixTask: (_cwd, _scope, findings) =>
`!echo applied-fixes based on: ${findings.split("\n")[0] ?? ""}`,
gate: () => undefined,
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
}
describe("check-runner integration", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-smoke-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("runs a smoke check, writes a marker, and marks the check complete", async () => {
registerCheck(smokeCheck());
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state).toBeDefined();
expect(state?.checks.smoke.status).toBe("complete");
expect(state?.status).toBe("complete");
const marker = await readFile(
join(cwd, ".pygienium", "smoke.marker"),
"utf8",
);
expect(marker.trim()).toBe("smoke-complete");
expect(state?.checks.smoke.findings).toContain("smoke-findings");
expect(
state?.checks.smoke.phases.map((p) => `${p.id}=${p.status}`).join(","),
).toContain("analysis=complete");
expect(
state?.checks.smoke.phases.find((p) => p.id === "fix"),
).toBeUndefined();
});
it("scan-only does not produce changes and skips the fix phase", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.smoke.fix).toBe(false);
expect(state?.checks.smoke.changes).toBeUndefined();
const fixPhase = state?.checks.smoke.phases.find((p) => p.id === "fix");
expect(fixPhase).toBeUndefined();
});
it("--fix runs the fix phase and records changes", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.smoke.status).toBe("complete");
expect(state?.checks.smoke.fix).toBe(true);
expect(state?.checks.smoke.changes).toContain("applied-fixes");
expect(state?.checks.smoke.phases.find((p) => p.id === "fix")?.status).toBe(
"complete",
);
});
it("persists run-state.json at the expected path", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
expect(runStatePath(cwd)).toBe(join(cwd, ".pygienium", "run-state.json"));
const raw = await readFile(runStatePath(cwd), "utf8");
expect(JSON.parse(raw).checks.smoke.status).toBe("complete");
});
it("marks a check skipped when the gate returns an error", async () => {
const gated: CheckDefinition = {
...smokeCheck(),
name: "gated",
label: "Gated",
gate: () => "no source files matched",
};
await handleCheckCommand(gated, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.gated.status).toBe("skipped");
expect(state?.checks.gated.error).toBe("no source files matched");
});
it("fails the check loudly when the agent never settles", async () => {
// Models the silent hang: the sub-agent session never resolves after
// its last tool call (stalled provider stream / hung retry), leaving
// the run stuck mid-phase with no state save and no completion. The
// watchdog must abort the phase with a visible error instead.
setAgentRunner(() => new Promise<AgentRunResult>(() => {}));
const prev = process.env[AGENT_TIMEOUT_ENV];
process.env[AGENT_TIMEOUT_ENV] = "50";
try {
await handleCheckCommand(smokeCheck(), "", stubCtx(cwd));
} finally {
if (prev === undefined) delete process.env[AGENT_TIMEOUT_ENV];
else process.env[AGENT_TIMEOUT_ENV] = prev;
}
const state = await loadRunState(cwd);
expect(state?.checks.smoke.status).toBe("failed");
expect(state?.checks.smoke.error).toContain("did not settle within");
expect(state?.checks.smoke.error).toContain("/pygienium-resume");
expect(
state?.checks.smoke.phases.find((p) => p.id === "analysis")?.status,
).toBe("failed");
expect(state?.status).toBe("failed");
});
});