refactor: wire worktree-session-state.js and auto-runtime-state.js

Instead of deleting these planned-extraction modules, implement them
properly:

worktree-session-state.js:
- Upgraded to canonical module with JSDoc, node:path imports
- Fixed getActiveWorktreeName() to use normalize/join/basename (was
  using fragile string.replaceAll + split('/') approach)
- Fixed ensureWorktreeOriginalCwdFromPath() to use sep instead of regex
- worktree-command.js now imports/re-exports all state functions from
  this module and removes its local 'let originalCwd = null'
- registerWorktreeCommand() recovery logic replaced with
  ensureWorktreeOriginalCwdFromPath() call

auto-runtime-state.js:
- Fixed to use getAutoSession() singleton instead of 'new AutoSession()'
  (was creating an isolated instance disconnected from auto.js state)
- auto.js now re-exports isAutoActive, isAutoPaused, markToolStart,
  markToolEnd from this module, removing duplicate implementations
- All state reads in auto-runtime-state.js delegate to the same
  singleton that auto.js manages

Test: updated worktree-fixes.test.mjs guard to match clearWorktreeOriginalCwd()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-11 14:24:50 +02:00
parent 5be5d6d438
commit 181a19ac65
5 changed files with 156 additions and 80 deletions

View file

@ -0,0 +1,53 @@
/**
* auto-runtime-state.js Thin facade over the AutoSession singleton for
* consumers that only need active/paused state and tool tracking, without
* importing the full auto.js module.
*
* Purpose: break the forced dependency on auto.js (>1700 lines) for modules
* that only need isAutoActive(), isAutoPaused(), markToolStart/End, or
* recordToolInvocationError. All state reads delegate to the same singleton
* that auto.js manages, so there is no separate instance.
*
* Consumer: forensics.js, rethink.js, ui/index.js, commands-handlers.js,
* bootstrap/ask-gate.js, steerable-autonomous-extension.js.
*/
import { getAutoSession } from "./auto/session.js";
import {
isDeterministicPolicyError,
isQueuedUserMessageSkip,
isToolInvocationError,
markToolEnd as markTrackedToolEnd,
markToolStart as markTrackedToolStart,
} from "./auto-tool-tracking.js";
export function getAutoRuntimeSnapshot() {
const s = getAutoSession();
return {
active: s.active,
paused: s.paused,
currentUnit: s.currentUnit ? { ...s.currentUnit } : null,
basePath: s.basePath,
};
}
export function isAutoActive() {
return getAutoSession().active;
}
export function isAutoPaused() {
return getAutoSession().paused;
}
export function markToolStart(toolCallId, toolName) {
markTrackedToolStart(toolCallId, getAutoSession().active, toolName);
}
export function markToolEnd(toolCallId) {
markTrackedToolEnd(toolCallId);
}
export function recordToolInvocationError(toolName, errorMsg) {
const s = getAutoSession();
if (!s.active) return;
if (
isToolInvocationError(errorMsg) ||
isQueuedUserMessageSkip(errorMsg) ||
isDeterministicPolicyError(errorMsg)
) {
s.lastToolInvocationError = `${toolName}: ${errorMsg}`;
}
}

View file

@ -79,12 +79,16 @@ import {
import { startUnitSupervision } from "./auto-timers.js"; import { startUnitSupervision } from "./auto-timers.js";
import { import {
getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs, getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs,
markToolEnd as _markToolEnd,
markToolStart as _markToolStart,
clearInFlightTools, clearInFlightTools,
isQueuedUserMessageSkip, isQueuedUserMessageSkip,
isToolInvocationError, isToolInvocationError,
} from "./auto-tool-tracking.js"; } from "./auto-tool-tracking.js";
export {
isAutoActive,
isAutoPaused,
markToolEnd,
markToolStart,
} from "./auto-runtime-state.js";
import { import {
autoWorktreeBranch, autoWorktreeBranch,
checkResourcesStale, checkResourcesStale,
@ -439,12 +443,7 @@ export function getAutoDashboardData() {
}; };
} }
// ─── Public API ─────────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────────
export function isAutoActive() { // isAutoActive, isAutoPaused, markToolStart, markToolEnd re-exported from auto-runtime-state.js above
return s.active;
}
export function isAutoPaused() {
return s.paused;
}
export function getAutoCommandContext() { export function getAutoCommandContext() {
return s.cmdCtx; return s.cmdCtx;
} }
@ -543,13 +542,7 @@ export function markResearchTerminalTransition() {
export function hasResearchTerminalTransition() { export function hasResearchTerminalTransition() {
return getAutoSession().researchTerminalTransition; return getAutoSession().researchTerminalTransition;
} }
// Tool tracking — delegates to auto-tool-tracking.ts // Tool tracking — delegates to auto-runtime-state.js (re-exported above)
export function markToolStart(toolCallId, toolName) {
_markToolStart(toolCallId, s.active, toolName);
}
export function markToolEnd(toolCallId) {
_markToolEnd(toolCallId);
}
const TASK_COMPLETE_TOOL_NAMES = new Set(["complete_task"]); const TASK_COMPLETE_TOOL_NAMES = new Set(["complete_task"]);
function normalizeTaskCompleteFailure(errorMsg) { function normalizeTaskCompleteFailure(errorMsg) {
return errorMsg return errorMsg

View file

@ -253,12 +253,12 @@ describe("WorktreeResolver.enterMilestone", () => {
// ─── originalCwd clear-on-success (worktree-command.js) ──────────────────── // ─── originalCwd clear-on-success (worktree-command.js) ────────────────────
describe("originalCwd lifecycle", () => { describe("originalCwd lifecycle", () => {
test("merge succeeds: originalCwd set to null", () => { test("merge succeeds: originalCwd cleared via clearWorktreeOriginalCwd()", () => {
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const sourcePath = join(__dirname, "..", "worktree-command.js"); const sourcePath = join(__dirname, "..", "worktree-command.js");
const source = readFileSync(sourcePath, "utf-8"); const source = readFileSync(sourcePath, "utf-8");
const mergeSuccessPattern = const mergeSuccessPattern =
/mergeWorktreeToMain\([^)]+\);\s*\n\s*\/\/ Merge succeeded[^\n]*\n\s*originalCwd = null;/; /mergeWorktreeToMain\([^)]+\);\s*\n\s*\/\/ Merge succeeded[^\n]*\n\s*clearWorktreeOriginalCwd\(\);/;
expect(source).toMatch(mergeSuccessPattern); expect(source).toMatch(mergeSuccessPattern);
}); });

View file

@ -16,7 +16,7 @@ import {
rmSync, rmSync,
unlinkSync, unlinkSync,
} from "node:fs"; } from "node:fs";
import { basename, join, normalize, sep } from "node:path"; import { join, normalize } from "node:path";
import { showConfirm } from "../shared/tui.js"; import { showConfirm } from "../shared/tui.js";
import { runWorktreePostCreateHook } from "./auto-worktree.js"; import { runWorktreePostCreateHook } from "./auto-worktree.js";
import { inferCommitType } from "./git-service.js"; import { inferCommitType } from "./git-service.js";
@ -41,34 +41,20 @@ import {
worktreeBranchName, worktreeBranchName,
worktreePath, worktreePath,
} from "./worktree-manager.js"; } from "./worktree-manager.js";
export {
/** clearWorktreeOriginalCwd,
* Tracks the original project root so we can switch back. ensureWorktreeOriginalCwdFromPath,
* Set when we first chdir into a worktree, cleared on return. getActiveWorktreeName,
*/ getWorktreeOriginalCwd,
let originalCwd = null; setWorktreeOriginalCwd,
/** } from "./worktree-session-state.js";
* Get the original project root if currently in a worktree, or null. import {
* Used to restore context after `/worktree merge` or `/worktree return`. clearWorktreeOriginalCwd,
*/ ensureWorktreeOriginalCwdFromPath,
export function getWorktreeOriginalCwd() { getActiveWorktreeName,
return originalCwd; getWorktreeOriginalCwd,
} setWorktreeOriginalCwd,
/** } from "./worktree-session-state.js";
* Get the name of the active worktree, or null if not in one.
* Extracts from .sf/worktrees/ path segment.
*/
export function getActiveWorktreeName() {
if (!originalCwd) return null;
const cwd = normalize(process.cwd());
const wtDir = normalize(join(originalCwd, ".sf", "worktrees"));
if (!cwd.startsWith(wtDir)) return null;
// Use basename on the first path segment after wtDir to handle both separators
// and avoid empty strings from trailing backslashes (split("/")[0] is fragile).
const rel = cwd.slice(wtDir.length).replace(/^[\\/]+/, "");
const name = basename(rel.split(/[\\/]/)[0] ?? rel);
return name || null;
}
// ─── Shared completions and handler (used by both /worktree and /wt) ──────── // ─── Shared completions and handler (used by both /worktree and /wt) ────────
function worktreeCompletions(prefix) { function worktreeCompletions(prefix) {
const parts = prefix.trim().split(/\s+/); const parts = prefix.trim().split(/\s+/);
@ -150,7 +136,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
return; return;
} }
// create and switch both do the same thing: switch if exists, create if not // create and switch both do the same thing: switch if exists, create if not
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const existing = listWorktrees(mainBase); const existing = listWorktrees(mainBase);
if (existing.some((wt) => wt.name === name)) { if (existing.some((wt) => wt.name === name)) {
await handleSwitch(basePath, name, ctx); await handleSwitch(basePath, name, ctx);
@ -165,7 +151,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean); .filter(Boolean);
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const activeWt = getActiveWorktreeName(); const activeWt = getActiveWorktreeName();
if (mergeArgs.length === 0) { if (mergeArgs.length === 0) {
// Bare "/worktree merge" — only valid when inside a worktree // Bare "/worktree merge" — only valid when inside a worktree
@ -197,7 +183,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
} }
if (trimmed === "remove" || trimmed.startsWith("remove ")) { if (trimmed === "remove" || trimmed.startsWith("remove ")) {
const name = trimmed.replace(/^remove\s*/, "").trim(); const name = trimmed.replace(/^remove\s*/, "").trim();
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
if (name === "all") { if (name === "all") {
await handleRemoveAll(mainBase, ctx); await handleRemoveAll(mainBase, ctx);
return; return;
@ -217,7 +203,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
); );
return; return;
} }
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const nameOnly = trimmed.split(/\s+/)[0]; const nameOnly = trimmed.split(/\s+/)[0];
if (trimmed !== nameOnly) { if (trimmed !== nameOnly) {
ctx.ui.notify( ctx.ui.notify(
@ -242,23 +228,15 @@ export async function handleWorktreeCommand(args, ctx, pi, alias) {
} }
/** Register /worktree and /wt commands with completion support. */ /** Register /worktree and /wt commands with completion support. */
export function registerWorktreeCommand(pi) { export function registerWorktreeCommand(pi) {
// Restore worktree state after /reload. // Restore worktree state after /reload — detects if process.cwd() is still
// The module-level originalCwd resets to null when extensions are re-loaded, // inside a worktree and recovers originalCwd from the path.
// but process.cwd() is still inside the worktree. Detect this and recover. ensureWorktreeOriginalCwdFromPath();
if (!originalCwd) {
const cwd = process.cwd();
const marker = `${sep}.sf${sep}worktrees${sep}`;
const markerIdx = cwd.indexOf(marker);
if (markerIdx !== -1) {
originalCwd = cwd.slice(0, markerIdx);
}
}
// Orphaned-worktree recovery: a crash or hang between the pre-merge chdir and // Orphaned-worktree recovery: a crash or hang between the pre-merge chdir and
// merge completion may leave a worktree registered in git but not tracked by // merge completion may leave a worktree registered in git but not tracked by
// originalCwd (because the old code cleared it prematurely). Detect such // originalCwd (because the old code cleared it prematurely). Detect such
// worktrees on reload and warn — so the user knows to run /worktree list and // worktrees on reload and warn — so the user knows to run /worktree list and
// merge or remove them manually. // merge or remove them manually.
if (!originalCwd) { if (!getWorktreeOriginalCwd()) {
try { try {
const cwd = process.cwd(); const cwd = process.cwd();
const worktrees = listWorktrees(cwd); const worktrees = listWorktrees(cwd);
@ -343,7 +321,7 @@ async function handleCreate(basePath, name, ctx) {
name, name,
); );
// Create from the main tree, not from inside another worktree // Create from the main tree, not from inside another worktree
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const info = createWorktree(mainBase, name); const info = createWorktree(mainBase, name);
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
const hookError = runWorktreePostCreateHook(mainBase, info.path); const hookError = runWorktreePostCreateHook(mainBase, info.path);
@ -351,7 +329,7 @@ async function handleCreate(basePath, name, ctx) {
ctx.ui.notify(hookError, "warning"); ctx.ui.notify(hookError, "warning");
} }
// Track original cwd before switching // Track original cwd before switching
if (!originalCwd) originalCwd = basePath; if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath);
const prevCwd = process.cwd(); const prevCwd = process.cwd();
process.chdir(info.path); process.chdir(info.path);
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
@ -405,7 +383,7 @@ async function handleCreate(basePath, name, ctx) {
} }
async function handleSwitch(basePath, name, ctx) { async function handleSwitch(basePath, name, ctx) {
try { try {
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const wtPath = worktreePath(mainBase, name); const wtPath = worktreePath(mainBase, name);
if (!existsSync(wtPath)) { if (!existsSync(wtPath)) {
ctx.ui.notify( ctx.ui.notify(
@ -421,7 +399,7 @@ async function handleSwitch(basePath, name, ctx) {
name, name,
); );
// Track original cwd before switching // Track original cwd before switching
if (!originalCwd) originalCwd = basePath; if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath);
const prevCwd = process.cwd(); const prevCwd = process.cwd();
process.chdir(wtPath); process.chdir(wtPath);
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
@ -448,7 +426,7 @@ async function handleSwitch(basePath, name, ctx) {
} }
} }
async function handleReturn(ctx) { async function handleReturn(ctx) {
if (!originalCwd) { if (!getWorktreeOriginalCwd()) {
ctx.ui.notify("Already in the main project tree.", "info"); ctx.ui.notify("Already in the main project tree.", "info");
return; return;
} }
@ -458,8 +436,8 @@ async function handleReturn(ctx) {
"worktree-return", "worktree-return",
"worktree", "worktree",
); );
const returnTo = originalCwd; const returnTo = getWorktreeOriginalCwd();
originalCwd = null; clearWorktreeOriginalCwd();
const prevCwd = process.cwd(); const prevCwd = process.cwd();
process.chdir(returnTo); process.chdir(returnTo);
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
@ -514,7 +492,7 @@ const CLR = {
}; };
async function handleList(basePath, ctx) { async function handleList(basePath, ctx) {
try { try {
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const worktrees = listWorktrees(mainBase); const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) { if (worktrees.length === 0) {
ctx.ui.notify( ctx.ui.notify(
@ -566,8 +544,8 @@ async function handleList(basePath, ctx) {
} }
lines.push(""); lines.push("");
} }
if (originalCwd) { if (getWorktreeOriginalCwd()) {
lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`); lines.push(` ${CLR.label("main tree")} ${CLR.path(getWorktreeOriginalCwd())}`);
} }
ctx.ui.notify(lines.join("\n"), "info"); ctx.ui.notify(lines.join("\n"), "info");
} catch (error) { } catch (error) {
@ -668,7 +646,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) {
// worktree on restart. originalCwd is cleared only in the success path below. // worktree on restart. originalCwd is cleared only in the success path below.
// The registerWorktreeCommand recovery logic reads process.cwd() on reload and // The registerWorktreeCommand recovery logic reads process.cwd() on reload and
// can restore originalCwd for orphaned worktree sessions. // can restore originalCwd for orphaned worktree sessions.
if (originalCwd) { if (getWorktreeOriginalCwd()) {
const prevCwd = process.cwd(); const prevCwd = process.cwd();
process.chdir(basePath); process.chdir(basePath);
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
@ -691,7 +669,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) {
try { try {
mergeWorktreeToMain(basePath, name, commitMessage); mergeWorktreeToMain(basePath, name, commitMessage);
// Merge succeeded — safe to clear the worktree tracking state now // Merge succeeded — safe to clear the worktree tracking state now
originalCwd = null; clearWorktreeOriginalCwd();
ctx.ui.notify( ctx.ui.notify(
[ [
`${CLR.ok("✓")} Merged ${CLR.name(name)}${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`, `${CLR.ok("✓")} Merged ${CLR.name(name)}${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`,
@ -763,7 +741,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) {
} }
async function handleRemove(basePath, name, ctx) { async function handleRemove(basePath, name, ctx) {
try { try {
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
// Validate the worktree exists before attempting removal // Validate the worktree exists before attempting removal
const worktrees = listWorktrees(mainBase); const worktrees = listWorktrees(mainBase);
const wt = worktrees.find((w) => w.name === name); const wt = worktrees.find((w) => w.name === name);
@ -787,9 +765,9 @@ async function handleRemove(basePath, name, ctx) {
const prevCwd = process.cwd(); const prevCwd = process.cwd();
removeWorktree(mainBase, name, { deleteBranch: true }); removeWorktree(mainBase, name, { deleteBranch: true });
// If we were in that worktree, removeWorktree chdir'd us out — clear tracking // If we were in that worktree, removeWorktree chdir'd us out — clear tracking
if (originalCwd && process.cwd() !== prevCwd) { if (getWorktreeOriginalCwd() && process.cwd() !== prevCwd) {
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
originalCwd = null; clearWorktreeOriginalCwd();
} }
ctx.ui.notify( ctx.ui.notify(
`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, `${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`,
@ -802,7 +780,7 @@ async function handleRemove(basePath, name, ctx) {
} }
async function handleRemoveAll(basePath, ctx) { async function handleRemoveAll(basePath, ctx) {
try { try {
const mainBase = originalCwd ?? basePath; const mainBase = getWorktreeOriginalCwd() ?? basePath;
const worktrees = listWorktrees(mainBase); const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) { if (worktrees.length === 0) {
ctx.ui.notify("No worktrees to remove.", "info"); ctx.ui.notify("No worktrees to remove.", "info");
@ -831,9 +809,9 @@ async function handleRemoveAll(basePath, ctx) {
} }
} }
// If we were in a worktree that got removed, clear tracking // If we were in a worktree that got removed, clear tracking
if (originalCwd && process.cwd() !== prevCwd) { if (getWorktreeOriginalCwd() && process.cwd() !== prevCwd) {
nudgeGitBranchCache(prevCwd); nudgeGitBranchCache(prevCwd);
originalCwd = null; clearWorktreeOriginalCwd();
} }
const lines = []; const lines = [];
if (removed.length > 0) if (removed.length > 0)

View file

@ -0,0 +1,52 @@
/**
* worktree-session-state.js Shared mutable state for the active worktree session.
*
* Purpose: isolate the originalCwd tracking variable so it can be read by other
* worktree modules (auto-worktree, worktree-root, etc.) without importing the full
* worktree-command.js command handler.
*
* Consumer: worktree-command.js (primary owner), any module that needs
* getActiveWorktreeName() or getWorktreeOriginalCwd() without the command handler.
*/
import { basename, join, normalize, sep } from "node:path";
let originalCwd = null;
/** Return the original project root if currently in a worktree, or null. */
export function getWorktreeOriginalCwd() {
return originalCwd;
}
/** Set the original project root when entering a worktree. */
export function setWorktreeOriginalCwd(cwd) {
originalCwd = cwd;
}
/** Clear the stored root when returning from a worktree. */
export function clearWorktreeOriginalCwd() {
originalCwd = null;
}
/**
* Detect and set originalCwd from the current process path if not already set.
* Used during /reload recovery when process.cwd() is still inside a worktree but
* the module-level variable was reset.
*/
export function ensureWorktreeOriginalCwdFromPath(cwd = process.cwd()) {
if (originalCwd) return originalCwd;
const marker = `${sep}.sf${sep}worktrees${sep}`;
const markerIdx = cwd.indexOf(marker);
if (markerIdx !== -1) {
originalCwd = cwd.slice(0, markerIdx);
}
return originalCwd;
}
/**
* Return the name of the active worktree, or null if not in one.
* Extracts the first path segment after .sf/worktrees/ using normalize to handle
* both separators correctly.
*/
export function getActiveWorktreeName() {
if (!originalCwd) return null;
const cwd = normalize(process.cwd());
const wtDir = normalize(join(originalCwd, ".sf", "worktrees"));
if (!cwd.startsWith(wtDir)) return null;
const rel = cwd.slice(wtDir.length).replace(/^[\\/]+/, "");
const name = basename(rel.split(/[\\/]/)[0] ?? rel);
return name || null;
}