From bd863e3e211f22a94e4737ee51ba87372c6d55fb Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 15:25:26 -0500 Subject: [PATCH 1/2] fix(gsd): resolve steer overrides to worktree path when worktree is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSteer used process.cwd() as the base path for appendOverride, which writes to project/.gsd/OVERRIDES.md. When auto-mode runs in a worktree, it reads from worktree/.gsd/ — so overrides written from a second terminal were never seen by the agent. Now checks for an active worktree via getAutoWorktreePath and writes the override there when one exists, falling back to the project root when no worktree is active. Closes #3476 --- .../extensions/gsd/commands-handlers.ts | 10 ++- .../gsd/tests/steer-worktree-path.test.ts | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/steer-worktree-path.test.ts diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts index e87e89bbc..57a701e5c 100644 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -21,6 +21,7 @@ import { filterDoctorIssues, } from "./doctor.js"; import { isAutoActive } from "./auto.js"; +import { getAutoWorktreePath } from "./auto-worktree.js"; import { projectRoot } from "./commands/context.js"; import { loadPrompt } from "./prompt-loader.js"; @@ -222,7 +223,14 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext, const sid = state.activeSlice?.id ?? "none"; const tid = state.activeTask?.id ?? "none"; const appliedAt = `${mid}/${sid}/${tid}`; - await appendOverride(basePath, change, appliedAt); + + // Resolve the correct target path: if a worktree is active for the current + // milestone, write the override there so the auto-mode agent sees it. + // Without this, steering from a second terminal writes to the project root + // .gsd/ while the agent reads from the worktree .gsd/ — the override is lost. + const wtPath = mid !== "none" ? getAutoWorktreePath(basePath, mid) : null; + const targetPath = wtPath ?? basePath; + await appendOverride(targetPath, change, appliedAt); if (isAutoActive()) { pi.sendMessage({ diff --git a/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts b/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts new file mode 100644 index 000000000..45ce5fa17 --- /dev/null +++ b/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts @@ -0,0 +1,66 @@ +// GSD Extension - Steer Worktree Path Resolution Test +// Regression test for #3476: /gsd steer must write overrides to the worktree .gsd/, +// not the project root .gsd/, when a worktree is active. + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { appendOverride, loadActiveOverrides } from "../files.ts"; + +describe("steer worktree path resolution (#3476)", () => { + let projectRoot: string; + let worktreePath: string; + + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "gsd-steer-wt-")); + mkdirSync(join(projectRoot, ".gsd"), { recursive: true }); + + // Simulate a worktree with its own .gsd directory + worktreePath = join(projectRoot, ".gsd", "worktrees", "M001"); + mkdirSync(join(worktreePath, ".gsd"), { recursive: true }); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + test("appendOverride writes to worktree .gsd/ when worktree path is used", async () => { + await appendOverride(worktreePath, "Use Postgres instead of SQLite", "M001/S01/T01"); + + // Override should be in the worktree .gsd/ + const wtOverrides = join(worktreePath, ".gsd", "OVERRIDES.md"); + assert.ok(existsSync(wtOverrides), "override file exists in worktree .gsd/"); + + const content = readFileSync(wtOverrides, "utf-8"); + assert.ok(content.includes("Use Postgres instead of SQLite"), "override content is correct"); + + // Override should NOT be in the project root .gsd/ + const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md"); + assert.ok(!existsSync(rootOverrides), "no override file in project root .gsd/"); + }); + + test("loadActiveOverrides reads from worktree .gsd/ when worktree path is used", async () => { + await appendOverride(worktreePath, "Switch to JWT auth", "M001/S02/T01"); + + // Loading from worktree should find the override + const wtOverrides = await loadActiveOverrides(worktreePath); + assert.equal(wtOverrides.length, 1, "one active override in worktree"); + assert.equal(wtOverrides[0].change, "Switch to JWT auth"); + + // Loading from project root should find nothing + const rootOverrides = await loadActiveOverrides(projectRoot); + assert.equal(rootOverrides.length, 0, "no overrides in project root"); + }); + + test("appendOverride falls back to project root when no worktree exists", async () => { + await appendOverride(projectRoot, "Use Redis cache", "M001/S01/T01"); + + const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md"); + assert.ok(existsSync(rootOverrides), "override file exists in project root .gsd/"); + + const content = readFileSync(rootOverrides, "utf-8"); + assert.ok(content.includes("Use Redis cache"), "override content is correct"); + }); +}); From ee8792463675e894021de3bf0710ac005ac2cee6 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 15:37:13 -0500 Subject: [PATCH 2/2] fix(gsd): gate steer worktree routing on active session, fix messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address adversarial review findings: 1. [high] Override routing now requires an active auto-mode session (in-process or remote via checkRemoteAutoSession) before writing to a worktree path. Previously, any existing worktree directory would receive the override even if no agent was running there — a leftover worktree from a previous session would silently eat the override. 2. [medium] Success messages now report the actual resolved override location (worktree vs project root .gsd/OVERRIDES.md) so operators know exactly where to look during recovery or manual rewrite. Additional tests cover: inactive worktree fallback, double-gate (autoRunning + valid .git), and getAutoWorktreePath null on missing .git. Closes #3476 --- .../extensions/gsd/commands-handlers.ts | 27 +++++++----- .../gsd/tests/steer-worktree-path.test.ts | 44 ++++++++++++++++++- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts index 57a701e5c..b40b7bc25 100644 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -20,7 +20,7 @@ import { selectDoctorScope, filterDoctorIssues, } from "./doctor.js"; -import { isAutoActive } from "./auto.js"; +import { isAutoActive, checkRemoteAutoSession } from "./auto.js"; import { getAutoWorktreePath } from "./auto-worktree.js"; import { projectRoot } from "./commands/context.js"; import { loadPrompt } from "./prompt-loader.js"; @@ -224,14 +224,19 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext, const tid = state.activeTask?.id ?? "none"; const appliedAt = `${mid}/${sid}/${tid}`; - // Resolve the correct target path: if a worktree is active for the current - // milestone, write the override there so the auto-mode agent sees it. - // Without this, steering from a second terminal writes to the project root - // .gsd/ while the agent reads from the worktree .gsd/ — the override is lost. - const wtPath = mid !== "none" ? getAutoWorktreePath(basePath, mid) : null; + // Resolve the correct target path: only route to a worktree when auto-mode + // is actively running there (in-process or remote). A worktree directory may + // exist from a previous session without being the active runtime path — + // writing there without a live session would silently drop the override. + const autoRunning = isAutoActive() || checkRemoteAutoSession(basePath).running; + const wtPath = autoRunning && mid !== "none" + ? getAutoWorktreePath(basePath, mid) + : null; const targetPath = wtPath ?? basePath; await appendOverride(targetPath, change, appliedAt); + const overrideLoc = wtPath ? "worktree `.gsd/OVERRIDES.md`" : "`.gsd/OVERRIDES.md`"; + if (isAutoActive()) { pi.sendMessage({ customType: "gsd-hard-steer", @@ -240,14 +245,14 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext, "", `**Override:** ${change}`, "", - "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.", + `This override has been saved to ${overrideLoc} and will be injected into all future task prompts.`, "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", "", "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", ].join("\n"), display: false, }, { triggerTurn: true }); - ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info"); + ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Will be applied before next task dispatch.`, "info"); } else { pi.sendMessage({ customType: "gsd-hard-steer", @@ -256,13 +261,13 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext, "", `**Override:** ${change}`, "", - "This override has been saved to `.gsd/OVERRIDES.md`.", - "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.", + `This override has been saved to ${overrideLoc}.`, + `Before continuing, read ${overrideLoc} and update the current plan documents to reflect this change.`, "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", ].join("\n"), display: false, }, { triggerTurn: true }); - ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info"); + ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Update plan documents to reflect this change.`, "info"); } } diff --git a/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts b/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts index 45ce5fa17..137eb6cd6 100644 --- a/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +++ b/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts @@ -4,10 +4,11 @@ import { describe, test, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { appendOverride, loadActiveOverrides } from "../files.ts"; +import { getAutoWorktreePath } from "../auto-worktree.ts"; describe("steer worktree path resolution (#3476)", () => { let projectRoot: string; @@ -63,4 +64,45 @@ describe("steer worktree path resolution (#3476)", () => { const content = readFileSync(rootOverrides, "utf-8"); assert.ok(content.includes("Use Redis cache"), "override content is correct"); }); + + test("getAutoWorktreePath returns null for worktree without valid .git file", () => { + // The worktree directory exists but has no .git file — this is an inactive/ + // leftover worktree. getAutoWorktreePath must return null so handleSteer + // does not route overrides to a dead worktree. + const result = getAutoWorktreePath(projectRoot, "M001"); + assert.equal(result, null, "returns null for worktree without .git file"); + }); + + test("override routing: inactive worktree directory should not receive overrides", async () => { + // Simulate the handleSteer path-resolution logic: + // When no auto-mode is running, even if a worktree dir exists, + // overrides must go to the project root. + const autoRunning = false; // no live session + const wtPath = autoRunning ? getAutoWorktreePath(projectRoot, "M001") : null; + const targetPath = wtPath ?? projectRoot; + + await appendOverride(targetPath, "Should go to project root", "M001/S01/T01"); + + const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md"); + const wtOverrides = join(worktreePath, ".gsd", "OVERRIDES.md"); + + assert.ok(existsSync(rootOverrides), "override written to project root"); + assert.ok(!existsSync(wtOverrides), "override NOT written to inactive worktree"); + }); + + test("override routing: active worktree with valid .git should receive overrides", async () => { + // Simulate the handleSteer path-resolution logic with active auto-mode. + // getAutoWorktreePath requires a valid .git file, so even with autoRunning=true, + // it returns null for our test worktree (no real .git). This confirms the + // double-gate: both autoRunning AND valid worktree must be true. + const autoRunning = true; + const wtPath = autoRunning ? getAutoWorktreePath(projectRoot, "M001") : null; + const targetPath = wtPath ?? projectRoot; + + // Without a valid .git file, falls back to project root + await appendOverride(targetPath, "Falls back without .git", "M001/S01/T01"); + + const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md"); + assert.ok(existsSync(rootOverrides), "override written to project root (no valid .git in worktree)"); + }); });