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(