fix: call cleanupQuickBranch on turn_end to squash-merge quick branch back (#3054)

cleanupQuickBranch() was exported from quick.ts but never called anywhere.
After a /gsd quick task completed, the user was left on the quick branch
with orphaned state in quick-return.json.

Register a turn_end hook in register-hooks.ts that calls cleanupQuickBranch()
after each agent turn. The function is already idempotent (no-op when no
quick-return state is pending), so it is safe to call on every turn.

Closes #2668

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:46:03 -04:00 committed by GitHub
parent dfb18c6e62
commit fb10141e9b
2 changed files with 102 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import { buildBeforeAgentStartResult } from "./system-context.js";
import { handleAgentEnd } from "./agent-end-recovery.js";
import { clearDiscussionFlowState, isDepthVerified, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution } from "./write-gate.js";
import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
import { cleanupQuickBranch } from "../quick.js";
import { getDiscussionMilestoneId } from "../guided-flow.js";
import { loadToolApiKeys } from "../commands-config.js";
import { loadFile, saveFile, formatContinue } from "../files.js";
@ -86,6 +87,17 @@ export function registerHooks(pi: ExtensionAPI): void {
await handleAgentEnd(pi, event, ctx);
});
// 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 () => {
try {
cleanupQuickBranch();
} catch {
// Best-effort: don't break the turn lifecycle if cleanup fails.
}
});
pi.on("session_before_compact", async () => {
if (isAutoActive() || isAutoPaused()) {
return { cancel: true };

View file

@ -0,0 +1,90 @@
/**
* Tests that cleanupQuickBranch is called on turn_end to squash-merge the
* quick branch back to the original branch after the agent completes.
*
* Relates to #2668: /gsd quick does not squash-merge branch back after agent
* completes task. cleanupQuickBranch() exists but is never invoked.
*
* The fix registers a turn_end hook in register-hooks.ts that calls
* cleanupQuickBranch() after each turn, which is a no-op when no quick-task
* state is pending.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// ─── Structural test: verify turn_end hook exists in register-hooks.ts ──────
describe("quick task turn_end cleanup (#2668)", () => {
const hooksSource = readFileSync(
join(import.meta.dirname, "..", "bootstrap", "register-hooks.ts"),
"utf-8",
);
it("register-hooks.ts imports cleanupQuickBranch from quick.ts", () => {
assert.ok(
hooksSource.includes("cleanupQuickBranch"),
"register-hooks.ts must reference cleanupQuickBranch",
);
// Verify it's imported (not just mentioned in a comment)
const importMatch = hooksSource.match(
/import\s*\{[^}]*cleanupQuickBranch[^}]*\}\s*from\s*["'][^"']*quick/,
);
assert.ok(
importMatch,
"cleanupQuickBranch must be imported from quick module",
);
});
it("registers a turn_end handler that calls cleanupQuickBranch", () => {
// Find the turn_end registration
const turnEndMatch = hooksSource.match(
/pi\.on\(\s*["']turn_end["']/,
);
assert.ok(
turnEndMatch,
"register-hooks.ts must register a turn_end handler",
);
// Extract the turn_end handler body — find everything from the pi.on("turn_end"
// to the matching closing });
const turnEndIdx = hooksSource.indexOf(turnEndMatch[0]);
assert.ok(turnEndIdx !== -1);
// Get the rest of the source from that point
const rest = hooksSource.slice(turnEndIdx);
// The handler must call cleanupQuickBranch
// Look for cleanupQuickBranch within the first handler body (up to first `});`)
const handlerEnd = rest.indexOf("});");
assert.ok(handlerEnd !== -1, "turn_end handler has a closing });");
const handlerBody = rest.slice(0, handlerEnd);
assert.ok(
handlerBody.includes("cleanupQuickBranch"),
"turn_end handler must call cleanupQuickBranch",
);
});
it("turn_end handler calls cleanupQuickBranch without arguments (uses cwd default)", () => {
// cleanupQuickBranch(basePath = process.cwd()) — calling without args is correct
// because the handler runs in the same process where handleQuick set up cwd
const turnEndIdx = hooksSource.indexOf('pi.on("turn_end"') !== -1
? hooksSource.indexOf('pi.on("turn_end"')
: hooksSource.indexOf("pi.on('turn_end'");
assert.ok(turnEndIdx !== -1);
const rest = hooksSource.slice(turnEndIdx);
const handlerEnd = rest.indexOf("});");
const handlerBody = rest.slice(0, handlerEnd);
// Should call cleanupQuickBranch() — either bare or with no-arg form
assert.ok(
handlerBody.includes("cleanupQuickBranch("),
"turn_end handler invokes cleanupQuickBranch()",
);
});
});