fix(sf): reload after self-feedback inline fixes

This commit is contained in:
Mikael Hugo 2026-05-02 16:12:23 +02:00
parent a4059e5871
commit f5290e41aa
5 changed files with 101 additions and 3 deletions

View file

@ -2185,6 +2185,7 @@ export class AgentSession {
})();
},
getSystemPrompt: () => this.systemPrompt,
requestReload: () => {},
},
);
}

View file

@ -1287,6 +1287,11 @@ export class InteractiveMode {
})();
},
getSystemPrompt: () => this.session.systemPrompt,
requestReload: () => {
setTimeout(() => {
void this.handleReloadCommand();
}, 0);
},
});
// Set up the extension shortcut handler on the default editor

View file

@ -367,12 +367,28 @@ export function registerHooks(
// Squash-merge quick-task branch back to the original branch after the
// agent turn completes (#2668). cleanupQuickBranch is a no-op when no
// quick-return state is pending, so this is safe to call on every turn.
pi.on("turn_end", async () => {
pi.on("turn_end", async (_event, ctx) => {
try {
cleanupQuickBranch();
} catch {
// Best-effort: don't break the turn lifecycle if cleanup fails.
}
try {
const { consumeCompletedInlineFixClaim } = await import(
"../self-feedback-drain.js"
);
const resolvedIds = consumeCompletedInlineFixClaim(process.cwd());
if (resolvedIds.length > 0) {
const requestReload = (
ctx as ExtensionContext & { requestReload?: (reason?: string) => void }
).requestReload;
requestReload?.(
`self-feedback inline fix resolved ${resolvedIds.length} entr${resolvedIds.length === 1 ? "y" : "ies"}`,
);
}
} catch {
// Best-effort: stale code should not break normal turn completion.
}
});
pi.on("session_before_compact", async () => {

View file

@ -8,7 +8,13 @@
* Consumer: session_start hook in bootstrap/register-hooks.ts.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type {
ExtensionAPI,
@ -56,6 +62,14 @@ function writeClaim(basePath: string, ids: string[]): void {
);
}
function clearClaim(basePath: string): void {
try {
unlinkSync(claimPath(basePath));
} catch {
/* non-fatal */
}
}
function sameIds(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((id, idx) => id === b[idx]);
}
@ -174,3 +188,31 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(
);
return candidates.length;
}
/**
* Consume a completed inline-fix claim when all claimed entries are resolved.
*
* Purpose: self-patches must become active without the operator manually
* noticing that files changed; this detects the end of the repair turn and lets
* the lifecycle hook request a deferred reload.
*
* Consumer: register-hooks.ts turn_end handler.
*/
export function consumeCompletedInlineFixClaim(basePath: string): string[] {
const claim = readClaim(basePath);
if (!claim || claim.ids.length === 0) return [];
const byId = new Map(
[...readAllSelfFeedback(basePath), ...readUpstreamSelfFeedback()].map(
(entry) => [entry.id, entry],
),
);
const allResolved = claim.ids.every((id) => {
const entry = byId.get(id);
return !entry || Boolean(entry.resolvedAt);
});
if (!allResolved) return [];
clearClaim(basePath);
return claim.ids;
}

View file

@ -3,8 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it } from "vitest";
import { recordSelfFeedback } from "../self-feedback.ts";
import { markResolved, recordSelfFeedback } from "../self-feedback.ts";
import {
consumeCompletedInlineFixClaim,
dispatchSelfFeedbackInlineFixIfNeeded,
selectInlineFixCandidates,
} from "../self-feedback-drain.ts";
@ -102,6 +103,39 @@ describe("self-feedback inline drain", () => {
assert.match(JSON.stringify(messages[0]), /sf-self-feedback-inline-fix/);
});
it("consumes the claim after the inline-fix entries are resolved", () => {
const root = makeForgeProject();
const recorded = recordSelfFeedback(
{
kind: "agent-infrastructure",
severity: "high",
summary: "Self patch landed but active runtime stayed stale",
source: "detector",
},
root,
);
assert.ok(recorded);
const ctx = { ui: { notify() {} } } as any;
const pi = { sendMessage() {} } as any;
assert.equal(dispatchSelfFeedbackInlineFixIfNeeded(root, ctx, pi), 1);
assert.deepEqual(consumeCompletedInlineFixClaim(root), []);
assert.equal(
markResolved(
recorded.entry.id,
{
reason: "verified reload request after inline fix",
evidence: { kind: "agent-fix", commitSha: "abc1234" },
},
root,
),
true,
);
assert.deepEqual(consumeCompletedInlineFixClaim(root), [recorded.entry.id]);
assert.deepEqual(consumeCompletedInlineFixClaim(root), []);
});
it("selects high priority upstream entries filed while sf ran in another repo", () => {
const root = makeForgeProject();
const sfHome = process.env.SF_HOME!;