test(idle-triage): lock in surfaceSelfFeedbackQueueOnIdle contract

Five unit tests covering the bail-time queue notifier landed in
001740680: notify-with-pointer when candidates exist, plural/singular
noun agreement, silent on empty queue, silent on non-forge basePath,
no-throw when downstream notify itself crashes (bail-path safety).

Locks in the contract for the partial-AC1 slice of sf-mp4rxkwb-l4baga
(autonomous loop surfaces the queue at idle) without yet touching the
larger remaining work (real self-feedback-triage unit type with
begin/dispatch/checkpoint/complete).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 08:11:10 +02:00
parent fa9baf71d5
commit 65c1914b1f
2 changed files with 153 additions and 1 deletions

View file

@ -138,7 +138,7 @@ import { closeoutAndStop, generateMilestoneReport, maybeFireProductAudit, should
* Best-effort: never throws, never blocks. If candidate selection fails or
* the basePath isn't a forge repo, this is a no-op.
*/
function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) {
export function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) {
try {
const candidates = selectInlineFixCandidates(basePath);
if (!Array.isArray(candidates) || candidates.length === 0) return;

View file

@ -0,0 +1,152 @@
/**
* phases-pre-dispatch-idle-triage.test.mjs verify the bail-time
* self-feedback queue notifier (sf-mp4rxkwb-l4baga partial).
*
* Scope: unit-test the surfaceSelfFeedbackQueueOnIdle helper in isolation.
* The wider runPreDispatch integration (which calls this helper from the
* "no-active-milestone" / "milestone-complete" bail paths) is not unit-
* tested here the helper is the contract and the call sites are
* straightforward two-line additions verified by code review + the full
* SF suite passing.
*/
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { surfaceSelfFeedbackQueueOnIdle } from "../auto/phases-pre-dispatch.js";
let tempDirs = [];
function makeForgeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-idle-triage-test-"));
tempDirs.push(dir);
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ name: "singularity-forge" }),
"utf-8",
);
mkdirSync(join(dir, ".sf"), { recursive: true });
return dir;
}
function writeEntries(basePath, entries) {
const path = join(basePath, ".sf", "self-feedback.jsonl");
writeFileSync(
path,
`${entries.map((e) => JSON.stringify(e)).join("\n")}\n`,
"utf-8",
);
}
function entry(overrides = {}) {
return {
schemaVersion: 1,
id: `sf-test-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
kind: "gap:test",
severity: "high",
blocking: false,
summary: "test entry",
source: "runtime",
...overrides,
};
}
function makeFakeCtx() {
const calls = [];
return {
calls,
ctx: {
ui: {
notify: (msg, level) => {
calls.push({ msg, level });
},
},
},
};
}
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});
describe("surfaceSelfFeedbackQueueOnIdle", () => {
test("notifies when open candidates exist with the canonical pointer to triage", () => {
const dir = makeForgeProject();
writeEntries(dir, [
entry({ id: "sf-a", severity: "high", summary: "first" }),
entry({ id: "sf-b", severity: "high", summary: "second" }),
]);
const { ctx, calls } = makeFakeCtx();
surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone");
expect(calls).toHaveLength(1);
expect(calls[0].level).toBe("warning");
expect(calls[0].msg).toContain("Idle (no-active-milestone)");
expect(calls[0].msg).toContain("2 self-feedback entries");
expect(calls[0].msg).toContain("sf headless triage --list");
expect(calls[0].msg).toContain("sf headless triage --run");
});
test("uses singular 'entry' for n=1", () => {
const dir = makeForgeProject();
writeEntries(dir, [entry({ id: "sf-only" })]);
const { ctx, calls } = makeFakeCtx();
surfaceSelfFeedbackQueueOnIdle(ctx, dir, "milestone-complete");
expect(calls).toHaveLength(1);
expect(calls[0].msg).toContain("1 self-feedback entry");
expect(calls[0].msg).toContain("milestone-complete");
});
test("is silent when no open candidates remain", () => {
const dir = makeForgeProject();
writeEntries(dir, [
entry({
id: "sf-resolved",
resolvedAt: "2026-05-14T00:00:00Z",
resolvedReason: "fixed",
resolvedEvidence: { kind: "agent-fix", commitSha: "abc123" },
}),
]);
const { ctx, calls } = makeFakeCtx();
surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone");
expect(calls).toHaveLength(0);
});
test("is silent on a non-forge basePath (best-effort no-op)", () => {
const dir = mkdtempSync(join(tmpdir(), "sf-idle-triage-nonforge-"));
tempDirs.push(dir);
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ name: "some-other-app" }),
"utf-8",
);
const { ctx, calls } = makeFakeCtx();
surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone");
expect(calls).toHaveLength(0);
});
test("does not throw when ctx.ui.notify itself throws", () => {
// Bail-path safety: a downstream notification failure must never
// take down the loop's clean exit.
const dir = makeForgeProject();
writeEntries(dir, [entry({ id: "sf-x" })]);
const ctx = {
ui: {
notify: () => {
throw new Error("ui crash");
},
},
};
expect(() =>
surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone"),
).not.toThrow();
});
});