fix(self-feedback-drain): route inline-fix dispatch via 'sf headless triage --apply' when SF_HEADLESS=1

The existing dispatch used pi.sendMessage to queue a chat followUp.
That works in interactive sf sessions but no chat agent is listening
in 'sf headless' / autonomous flows — the message is queued and never
delivered, leaving the high/critical blocker active on every iteration.

When SF_HEADLESS=1, spawn the same triage-decider → review-code pipeline
(via the already-shipped 'sf headless triage --apply' subprocess) instead.
The autonomous loop then sees resolved entries via DB on the next gate
check, no chat agent required.

Forge-only: the dispatcher still only operates in the SF repo itself —
`readAllSelfFeedback` for non-forge repos returns the upstream-feedback
log (SF developer work), which must not be auto-dispatched from inside
consumer projects. Documented that constraint inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 21:39:47 +02:00
parent b0ebe7ce18
commit 7e1631618a

View file

@ -7,7 +7,7 @@
*
* Consumer: session_start hook in bootstrap/register-hooks.ts.
*/
import { execFileSync } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
@ -207,6 +207,11 @@ function effectiveEffort(entry) {
}
export function selectInlineFixCandidates(basePath) {
// Forge-only: in consumer projects, `readAllSelfFeedback` returns the
// global upstream-feedback log (~/.sf/agent/upstream-feedback.jsonl),
// which lists SF-developer work items. We must not auto-dispatch a
// triage agent against those from inside a consumer project — that
// would let any downstream project mark SF's own work as resolved.
if (!isForgeRepo(basePath)) return [];
return readAllSelfFeedback(basePath)
.filter((entry) => {
@ -306,6 +311,38 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
// Mode transition is best-effort
}
writeClaim(basePath, ids);
// Headless / autonomous surfaces have no interactive chat agent listening
// for `pi.sendMessage` — the followUp gets queued but never delivered, so
// the blocker persists across iterations. Route through `sf headless triage
// --apply` instead, which dispatches the same triage-decider → review-code
// pipeline through SF's own subprocess machinery (router-resolved model,
// watchdog, trust gate). Fire-and-forget: the autonomous loop will see the
// resolved entries via DB on the next iteration's gate check.
if (process.env.SF_HEADLESS === "1") {
ctx.ui.notify(
`Dispatching self-feedback inline fix via 'sf headless triage --apply' for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"} (headless surface).`,
"warning",
);
const sfBin = process.env.SF_BIN_PATH || process.argv[1];
if (!sfBin) {
writeFailedClaim(basePath, ids, "SF_BIN_PATH unavailable");
return 0;
}
try {
const child = spawn(
process.execPath,
[sfBin, "headless", "triage", "--apply", "--json"],
{ cwd: basePath, stdio: ["ignore", "ignore", "ignore"], detached: true },
);
child.on("error", (err) => {
writeFailedClaim(basePath, ids, getErrorMessage(err));
});
child.unref();
} catch (err) {
writeFailedClaim(basePath, ids, getErrorMessage(err));
}
return candidates.length;
}
const prompt = buildInlineFixPrompt(candidates);
ctx.ui.notify(
`Queueing self-feedback inline fix for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"}.`,