diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index bdcbc89df..835700e18 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -2185,6 +2185,7 @@ export class AgentSession { })(); }, getSystemPrompt: () => this.systemPrompt, + requestReload: () => {}, }, ); } diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index cccde7adb..373fb7396 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 3102d93bb..696f027d2 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -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 () => { diff --git a/src/resources/extensions/sf/self-feedback-drain.ts b/src/resources/extensions/sf/self-feedback-drain.ts index d3bd1876a..f121e6680 100644 --- a/src/resources/extensions/sf/self-feedback-drain.ts +++ b/src/resources/extensions/sf/self-feedback-drain.ts @@ -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; +} diff --git a/src/resources/extensions/sf/tests/self-feedback-drain.test.ts b/src/resources/extensions/sf/tests/self-feedback-drain.test.ts index d31b3731a..ad4ec5408 100644 --- a/src/resources/extensions/sf/tests/self-feedback-drain.test.ts +++ b/src/resources/extensions/sf/tests/self-feedback-drain.test.ts @@ -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!;