From fb10141e9be523b5552650ea6426275ad9926d6a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:46:03 -0400 Subject: [PATCH] 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 --- .../gsd/bootstrap/register-hooks.ts | 12 +++ .../gsd/tests/quick-turn-end-cleanup.test.ts | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/quick-turn-end-cleanup.test.ts diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 4965d31cc..d8690c7a3 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -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 }; diff --git a/src/resources/extensions/gsd/tests/quick-turn-end-cleanup.test.ts b/src/resources/extensions/gsd/tests/quick-turn-end-cleanup.test.ts new file mode 100644 index 000000000..5051a8567 --- /dev/null +++ b/src/resources/extensions/gsd/tests/quick-turn-end-cleanup.test.ts @@ -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()", + ); + }); +});