Skip to content

Commit c0b2610

Browse files
authored
fix: emit agent_end after abort during tool execution (#1414) (#1417)
* 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
1 parent 9bafd68 commit c0b2610

File tree

6 files changed

+105
-9
lines changed

6 files changed

+105
-9
lines changed

packages/pi-coding-agent/src/core/agent-session.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,15 @@ export class AgentSession {
13591359
this.abortRetry();
13601360
this.agent.abort();
13611361
await this.agent.waitForIdle();
1362+
// Ensure agent_end is emitted even when abort interrupts a tool call (#1414).
1363+
// The agent may go idle without emitting agent_end if the abort happens
1364+
// between tool execution and response processing.
1365+
if (!this.isStreaming && this._extensionRunner) {
1366+
await this._extensionRunner.emit({
1367+
type: "agent_end",
1368+
messages: this.agent.state.messages,
1369+
});
1370+
}
13621371
}
13631372

13641373
/**

src/resource-loader.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi
1919
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
2020
const distResources = join(packageRoot, 'dist', 'resources')
2121
const srcResources = join(packageRoot, 'src', 'resources')
22-
const resourcesDir = existsSync(distResources) ? distResources : srcResources
22+
const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents')))
23+
? distResources
24+
: srcResources
2325
const bundledExtensionsDir = join(resourcesDir, 'extensions')
2426
const resourceVersionManifestName = 'managed-resources.json'
2527

src/resources/extensions/gsd/auto-worktree.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,77 @@ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: stri
162162
return { synced };
163163
}
164164

165+
/**
166+
* Sync milestone artifacts from worktree back to the main external state directory.
167+
* Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION,
168+
* updated ROADMAP) are visible from the project root (#1412).
169+
*
170+
* Only syncs .gsd/milestones/ content — root-level files (DECISIONS, REQUIREMENTS, etc.)
171+
* are handled by the merge itself.
172+
*/
173+
export function syncWorktreeStateBack(mainBasePath: string, worktreePath: string, milestoneId: string): { synced: string[] } {
174+
const mainGsd = gsdRoot(mainBasePath);
175+
const wtGsd = gsdRoot(worktreePath);
176+
const synced: string[] = [];
177+
178+
// If both resolve to the same directory (symlink), no sync needed
179+
try {
180+
const mainResolved = realpathSync(mainGsd);
181+
const wtResolved = realpathSync(wtGsd);
182+
if (mainResolved === wtResolved) return { synced };
183+
} catch {
184+
// Can't resolve — proceed with sync
185+
}
186+
187+
const wtMilestoneDir = join(wtGsd, "milestones", milestoneId);
188+
const mainMilestoneDir = join(mainGsd, "milestones", milestoneId);
189+
190+
if (!existsSync(wtMilestoneDir)) return { synced };
191+
mkdirSync(mainMilestoneDir, { recursive: true });
192+
193+
// Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT)
194+
try {
195+
for (const entry of readdirSync(wtMilestoneDir, { withFileTypes: true })) {
196+
if (entry.isFile() && entry.name.endsWith(".md")) {
197+
const src = join(wtMilestoneDir, entry.name);
198+
const dst = join(mainMilestoneDir, entry.name);
199+
try {
200+
cpSync(src, dst, { force: true });
201+
synced.push(`milestones/${milestoneId}/${entry.name}`);
202+
} catch { /* non-fatal */ }
203+
}
204+
}
205+
} catch { /* non-fatal */ }
206+
207+
// Sync slice-level files (summaries, UATs)
208+
const wtSlicesDir = join(wtMilestoneDir, "slices");
209+
const mainSlicesDir = join(mainMilestoneDir, "slices");
210+
if (existsSync(wtSlicesDir)) {
211+
try {
212+
for (const sliceEntry of readdirSync(wtSlicesDir, { withFileTypes: true })) {
213+
if (!sliceEntry.isDirectory()) continue;
214+
const sid = sliceEntry.name;
215+
const wtSliceDir = join(wtSlicesDir, sid);
216+
const mainSliceDir = join(mainSlicesDir, sid);
217+
mkdirSync(mainSliceDir, { recursive: true });
218+
219+
for (const fileEntry of readdirSync(wtSliceDir, { withFileTypes: true })) {
220+
if (fileEntry.isFile() && fileEntry.name.endsWith(".md")) {
221+
const src = join(wtSliceDir, fileEntry.name);
222+
const dst = join(mainSliceDir, fileEntry.name);
223+
try {
224+
cpSync(src, dst, { force: true });
225+
synced.push(`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`);
226+
} catch { /* non-fatal */ }
227+
}
228+
}
229+
}
230+
} catch { /* non-fatal */ }
231+
}
232+
233+
return { synced };
234+
}
235+
165236
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
166237

167238
/**

src/resources/extensions/gsd/auto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import {
131131
getAutoWorktreeOriginalBase,
132132
mergeMilestoneToMain,
133133
autoWorktreeBranch,
134+
syncWorktreeStateBack,
134135
} from "./auto-worktree.js";
135136
import { pruneQueueOrder } from "./queue-order.js";
136137
import { consumeSignal } from "./session-status-io.js";
@@ -377,6 +378,16 @@ function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "tr
377378
// Worktree merge path
378379
if (isInAutoWorktree(s.basePath) && s.originalBasePath) {
379380
try {
381+
// Sync completion artifacts from worktree → external state before merge (#1412)
382+
try {
383+
const { synced } = syncWorktreeStateBack(s.originalBasePath, s.basePath, milestoneId);
384+
if (synced.length > 0) {
385+
debugLog("worktree-reverse-sync", { milestoneId, synced: synced.length });
386+
}
387+
} catch (syncErr) {
388+
debugLog("worktree-reverse-sync-failed", { milestoneId, error: getErrorMessage(syncErr) });
389+
}
390+
380391
const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP");
381392
if (!roadmapPath) {
382393
teardownAutoWorktree(s.originalBasePath, milestoneId);

src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ function run(command: string, cwd: string): string {
1313
}
1414

1515
async function main(): Promise<void> {
16-
const base = mkdtempSync(join(tmpdir(), "gsd-repo-identity-"));
17-
const stateDir = mkdtempSync(join(tmpdir(), "gsd-state-"));
16+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-")));
17+
const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-")));
1818

1919
try {
2020
process.env.GSD_STATE_DIR = stateDir;
@@ -38,7 +38,7 @@ async function main(): Promise<void> {
3838
assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir");
3939
assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists");
4040
assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink");
41-
assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "worktree .gsd symlink resolves to main repo external state dir");
41+
assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir");
4242

4343
console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ===");
4444
const staleState = join(stateDir, "projects", "stale-worktree-state");
@@ -47,7 +47,7 @@ async function main(): Promise<void> {
4747
symlinkSync(staleState, join(worktreePath, ".gsd"), "junction");
4848
const healedState = ensureGsdSymlink(worktreePath);
4949
assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir");
50-
assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "healed worktree symlink resolves to canonical external state dir");
50+
assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir");
5151

5252
console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ===");
5353
rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true });

src/tests/file-watcher.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ test("settings.json change emits settings-changed event", async () => {
5454
const bus = createMockEventBus();
5555

5656
await startFileWatcher(dir, bus);
57+
await delay(200);
5758

5859
writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true }));
5960
// Wait for debounce (300ms) + filesystem propagation
60-
await delay(600);
61+
await delay(800);
6162

6263
const matched = bus.events.filter((e) => e.channel === "settings-changed");
6364
assert.ok(matched.length > 0, "should emit settings-changed event");
@@ -68,9 +69,10 @@ test("auth.json change emits auth-changed event", async () => {
6869
const bus = createMockEventBus();
6970

7071
await startFileWatcher(dir, bus);
72+
await delay(200);
7173

7274
writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" }));
73-
await delay(600);
75+
await delay(800);
7476

7577
const matched = bus.events.filter((e) => e.channel === "auth-changed");
7678
assert.ok(matched.length > 0, "should emit auth-changed event");
@@ -81,9 +83,10 @@ test("models.json change emits models-changed event", async () => {
8183
const bus = createMockEventBus();
8284

8385
await startFileWatcher(dir, bus);
86+
await delay(200);
8487

8588
writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" }));
86-
await delay(600);
89+
await delay(800);
8790

8891
const matched = bus.events.filter((e) => e.channel === "models-changed");
8992
assert.ok(matched.length > 0, "should emit models-changed event");
@@ -133,7 +136,7 @@ test("debouncing coalesces rapid changes into one event", async () => {
133136
for (let i = 0; i < 5; i++) {
134137
writeFileSync(join(dir, "settings.json"), JSON.stringify({ i }));
135138
}
136-
await delay(600);
139+
await delay(800);
137140

138141
const matched = bus.events.filter((e) => e.channel === "settings-changed");
139142
assert.strictEqual(

0 commit comments

Comments
 (0)