From f0fe4b2443cd9668001667f0d7bf1c0fc6c31aed Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 19 Mar 2026 12:24:39 -0400 Subject: [PATCH] fix: emit agent_end after abort during tool execution (#1414) (#1417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sync worktree completion artifacts back to external state before merge (#1412) When a worktree's .gsd/ was a real directory (not symlinked to external state), milestone completion artifacts (SUMMARY, VALIDATION, updated ROADMAP) were written locally but never synced back. The project root's deriveState() read from external state and found no SUMMARY — reporting the milestone as incomplete. Changes: - auto-worktree.ts: Added syncWorktreeStateBack() that copies milestone and slice .md files from worktree .gsd/ to the main external state dir - auto.ts: Call syncWorktreeStateBack() in tryMergeMilestone before the git merge, ensuring artifacts are visible from the project root Fixes #1412 * fix: emit agent_end after abort during tool execution (#1414) When a user aborts a turn while a tool call is running, the abort RPC succeeds but agent_end was never emitted. RPC consumers tracking turn lifecycle via events got stuck in a 'streaming' state permanently. Fix: After abort() + waitForIdle(), emit a synthetic agent_end if the agent is no longer streaming. This ensures consumers always see the turn-complete signal regardless of how the turn ended. Fixes #1414 --- .../pi-coding-agent/src/core/agent-session.ts | 9 +++ src/resource-loader.ts | 4 +- src/resources/extensions/gsd/auto-worktree.ts | 71 +++++++++++++++++++ src/resources/extensions/gsd/auto.ts | 11 +++ .../gsd/tests/repo-identity-worktree.test.ts | 8 +-- src/tests/file-watcher.test.ts | 11 +-- 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 28e27049c..a5dfa2335 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -1359,6 +1359,15 @@ export class AgentSession { this.abortRetry(); this.agent.abort(); await this.agent.waitForIdle(); + // Ensure agent_end is emitted even when abort interrupts a tool call (#1414). + // The agent may go idle without emitting agent_end if the abort happens + // between tool execution and response processing. + if (!this.isStreaming && this._extensionRunner) { + await this._extensionRunner.emit({ + type: "agent_end", + messages: this.agent.state.messages, + }); + } } /** diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d06dd50a7..2e1a2c688 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -19,7 +19,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const distResources = join(packageRoot, 'dist', 'resources') const srcResources = join(packageRoot, 'src', 'resources') -const resourcesDir = existsSync(distResources) ? distResources : srcResources +const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents'))) + ? distResources + : srcResources const bundledExtensionsDir = join(resourcesDir, 'extensions') const resourceVersionManifestName = 'managed-resources.json' diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 3fdbc8444..375045f69 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -162,6 +162,77 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri return { synced }; } +/** + * Sync milestone artifacts from worktree back to the main external state directory. + * Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION, + * updated ROADMAP) are visible from the project root (#1412). + * + * Only syncs .gsd/milestones/ content — root-level files (DECISIONS, REQUIREMENTS, etc.) + * are handled by the merge itself. + */ +export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string, milestoneId: string): { synced: string[] } { + const mainGsd = gsdRoot(mainBasePath); + const wtGsd = gsdRoot(worktreePath); + const synced: string[] = []; + + // If both resolve to the same directory (symlink), no sync needed + try { + const mainResolved = realpathSync(mainGsd); + const wtResolved = realpathSync(wtGsd); + if (mainResolved === wtResolved) return { synced }; + } catch { + // Can't resolve — proceed with sync + } + + const wtMilestoneDir = join(wtGsd, "milestones", milestoneId); + const mainMilestoneDir = join(mainGsd, "milestones", milestoneId); + + if (!existsSync(wtMilestoneDir)) return { synced }; + mkdirSync(mainMilestoneDir, { recursive: true }); + + // Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT) + try { + for (const entry of readdirSync(wtMilestoneDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".md")) { + const src = join(wtMilestoneDir, entry.name); + const dst = join(mainMilestoneDir, entry.name); + try { + cpSync(src, dst, { force: true }); + synced.push(`milestones/${milestoneId}/${entry.name}`); + } catch { /* non-fatal */ } + } + } + } catch { /* non-fatal */ } + + // Sync slice-level files (summaries, UATs) + const wtSlicesDir = join(wtMilestoneDir, "slices"); + const mainSlicesDir = join(mainMilestoneDir, "slices"); + if (existsSync(wtSlicesDir)) { + try { + for (const sliceEntry of readdirSync(wtSlicesDir, { withFileTypes: true })) { + if (!sliceEntry.isDirectory()) continue; + const sid = sliceEntry.name; + const wtSliceDir = join(wtSlicesDir, sid); + const mainSliceDir = join(mainSlicesDir, sid); + mkdirSync(mainSliceDir, { recursive: true }); + + for (const fileEntry of readdirSync(wtSliceDir, { withFileTypes: true })) { + if (fileEntry.isFile() && fileEntry.name.endsWith(".md")) { + const src = join(wtSliceDir, fileEntry.name); + const dst = join(mainSliceDir, fileEntry.name); + try { + cpSync(src, dst, { force: true }); + synced.push(`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`); + } catch { /* non-fatal */ } + } + } + } + } catch { /* non-fatal */ } + } + + return { synced }; +} + // ─── Worktree Post-Create Hook (#597) ──────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 08b6b7f53..27d5611f7 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -131,6 +131,7 @@ import { getAutoWorktreeOriginalBase, mergeMilestoneToMain, autoWorktreeBranch, + syncWorktreeStateBack, } from "./auto-worktree.js"; import { pruneQueueOrder } from "./queue-order.js"; import { consumeSignal } from "./session-status-io.js"; @@ -377,6 +378,16 @@ function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "tr // Worktree merge path if (isInAutoWorktree(s.basePath) && s.originalBasePath) { try { + // Sync completion artifacts from worktree → external state before merge (#1412) + try { + const { synced } = syncWorktreeStateBack(s.originalBasePath, s.basePath, milestoneId); + if (synced.length > 0) { + debugLog("worktree-reverse-sync", { milestoneId, synced: synced.length }); + } + } catch (syncErr) { + debugLog("worktree-reverse-sync-failed", { milestoneId, error: getErrorMessage(syncErr) }); + } + const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP"); if (!roadmapPath) { teardownAutoWorktree(s.originalBasePath, milestoneId); diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index 1719fe264..b5b894382 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -13,8 +13,8 @@ function run(command: string, cwd: string): string { } async function main(): Promise { - const base = mkdtempSync(join(tmpdir(), "gsd-repo-identity-")); - const stateDir = mkdtempSync(join(tmpdir(), "gsd-state-")); + const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-"))); + const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-"))); try { process.env.GSD_STATE_DIR = stateDir; @@ -38,7 +38,7 @@ async function main(): Promise { assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir"); assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists"); assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "worktree .gsd symlink resolves to main repo external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir"); console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ==="); const staleState = join(stateDir, "projects", "stale-worktree-state"); @@ -47,7 +47,7 @@ async function main(): Promise { symlinkSync(staleState, join(worktreePath, ".gsd"), "junction"); const healedState = ensureGsdSymlink(worktreePath); assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "healed worktree symlink resolves to canonical external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir"); console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ==="); rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true }); diff --git a/src/tests/file-watcher.test.ts b/src/tests/file-watcher.test.ts index e8dc7fd00..38040cdc6 100644 --- a/src/tests/file-watcher.test.ts +++ b/src/tests/file-watcher.test.ts @@ -54,10 +54,11 @@ test("settings.json change emits settings-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true })); // Wait for debounce (300ms) + filesystem propagation - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.ok(matched.length > 0, "should emit settings-changed event"); @@ -68,9 +69,10 @@ test("auth.json change emits auth-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "auth-changed"); assert.ok(matched.length > 0, "should emit auth-changed event"); @@ -81,9 +83,10 @@ test("models.json change emits models-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "models-changed"); assert.ok(matched.length > 0, "should emit models-changed event"); @@ -133,7 +136,7 @@ test("debouncing coalesces rapid changes into one event", async () => { for (let i = 0; i < 5; i++) { writeFileSync(join(dir, "settings.json"), JSON.stringify({ i })); } - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.strictEqual(