From ee8792463675e894021de3bf0710ac005ac2cee6 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 15:37:13 -0500 Subject: [PATCH] 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)"); + }); });