fix(gsd): gate steer worktree routing on active session, fix messaging

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
This commit is contained in:
Jeremy 2026-04-04 15:37:13 -05:00
parent bd863e3e21
commit ee87924636
2 changed files with 59 additions and 12 deletions

View file

@ -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");
}
}

View file

@ -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)");
});
});