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

View file

@ -253,12 +253,12 @@ describe("WorktreeResolver.enterMilestone", () => {
// ─── originalCwd clear-on-success (worktree-command.js) ────────────────────
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 sourcePath = join(__dirname, "..", "worktree-command.js");
const source = readFileSync(sourcePath, "utf-8");
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);
});

View file

@ -16,7 +16,7 @@ import {
rmSync,
unlinkSync,
} from "node:fs";
import { basename, join, normalize, sep } from "node:path";
import { join, normalize } from "node:path";
import { showConfirm } from "../shared/tui.js";
import { runWorktreePostCreateHook } from "./auto-worktree.js";
import { inferCommitType } from "./git-service.js";
@ -41,34 +41,20 @@ import {
worktreeBranchName,
worktreePath,
} from "./worktree-manager.js";
/**
* Tracks the original project root so we can switch back.
* Set when we first chdir into a worktree, cleared on return.
*/
let originalCwd = null;
/**
* Get the original project root if currently in a worktree, or null.
* Used to restore context after `/worktree merge` or `/worktree return`.
*/
export function getWorktreeOriginalCwd() {
return originalCwd;
}
/**
* 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;
}
export {
clearWorktreeOriginalCwd,
ensureWorktreeOriginalCwdFromPath,
getActiveWorktreeName,
getWorktreeOriginalCwd,
setWorktreeOriginalCwd,
} from "./worktree-session-state.js";
import {
clearWorktreeOriginalCwd,
ensureWorktreeOriginalCwdFromPath,
getActiveWorktreeName,
getWorktreeOriginalCwd,
setWorktreeOriginalCwd,
} from "./worktree-session-state.js";
// ─── Shared completions and handler (used by both /worktree and /wt) ────────
function worktreeCompletions(prefix) {
const parts = prefix.trim().split(/\s+/);
@ -150,7 +136,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
return;
}
// 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);
if (existing.some((wt) => wt.name === name)) {
await handleSwitch(basePath, name, ctx);
@ -165,7 +151,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
.trim()
.split(/\s+/)
.filter(Boolean);
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const activeWt = getActiveWorktreeName();
if (mergeArgs.length === 0) {
// 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 ")) {
const name = trimmed.replace(/^remove\s*/, "").trim();
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
if (name === "all") {
await handleRemoveAll(mainBase, ctx);
return;
@ -217,7 +203,7 @@ async function worktreeHandler(args, ctx, pi, alias) {
);
return;
}
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const nameOnly = trimmed.split(/\s+/)[0];
if (trimmed !== nameOnly) {
ctx.ui.notify(
@ -242,23 +228,15 @@ export async function handleWorktreeCommand(args, ctx, pi, alias) {
}
/** Register /worktree and /wt commands with completion support. */
export function registerWorktreeCommand(pi) {
// Restore worktree state after /reload.
// The module-level originalCwd resets to null when extensions are re-loaded,
// but process.cwd() is still inside the worktree. Detect this and recover.
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);
}
}
// Restore worktree state after /reload — detects if process.cwd() is still
// inside a worktree and recovers originalCwd from the path.
ensureWorktreeOriginalCwdFromPath();
// 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
// originalCwd (because the old code cleared it prematurely). Detect such
// worktrees on reload and warn — so the user knows to run /worktree list and
// merge or remove them manually.
if (!originalCwd) {
if (!getWorktreeOriginalCwd()) {
try {
const cwd = process.cwd();
const worktrees = listWorktrees(cwd);
@ -343,7 +321,7 @@ async function handleCreate(basePath, name, ctx) {
name,
);
// Create from the main tree, not from inside another worktree
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const info = createWorktree(mainBase, name);
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
const hookError = runWorktreePostCreateHook(mainBase, info.path);
@ -351,7 +329,7 @@ async function handleCreate(basePath, name, ctx) {
ctx.ui.notify(hookError, "warning");
}
// Track original cwd before switching
if (!originalCwd) originalCwd = basePath;
if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath);
const prevCwd = process.cwd();
process.chdir(info.path);
nudgeGitBranchCache(prevCwd);
@ -405,7 +383,7 @@ async function handleCreate(basePath, name, ctx) {
}
async function handleSwitch(basePath, name, ctx) {
try {
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const wtPath = worktreePath(mainBase, name);
if (!existsSync(wtPath)) {
ctx.ui.notify(
@ -421,7 +399,7 @@ async function handleSwitch(basePath, name, ctx) {
name,
);
// Track original cwd before switching
if (!originalCwd) originalCwd = basePath;
if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath);
const prevCwd = process.cwd();
process.chdir(wtPath);
nudgeGitBranchCache(prevCwd);
@ -448,7 +426,7 @@ async function handleSwitch(basePath, name, ctx) {
}
}
async function handleReturn(ctx) {
if (!originalCwd) {
if (!getWorktreeOriginalCwd()) {
ctx.ui.notify("Already in the main project tree.", "info");
return;
}
@ -458,8 +436,8 @@ async function handleReturn(ctx) {
"worktree-return",
"worktree",
);
const returnTo = originalCwd;
originalCwd = null;
const returnTo = getWorktreeOriginalCwd();
clearWorktreeOriginalCwd();
const prevCwd = process.cwd();
process.chdir(returnTo);
nudgeGitBranchCache(prevCwd);
@ -514,7 +492,7 @@ const CLR = {
};
async function handleList(basePath, ctx) {
try {
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) {
ctx.ui.notify(
@ -566,8 +544,8 @@ async function handleList(basePath, ctx) {
}
lines.push("");
}
if (originalCwd) {
lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`);
if (getWorktreeOriginalCwd()) {
lines.push(` ${CLR.label("main tree")} ${CLR.path(getWorktreeOriginalCwd())}`);
}
ctx.ui.notify(lines.join("\n"), "info");
} 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.
// The registerWorktreeCommand recovery logic reads process.cwd() on reload and
// can restore originalCwd for orphaned worktree sessions.
if (originalCwd) {
if (getWorktreeOriginalCwd()) {
const prevCwd = process.cwd();
process.chdir(basePath);
nudgeGitBranchCache(prevCwd);
@ -691,7 +669,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) {
try {
mergeWorktreeToMain(basePath, name, commitMessage);
// Merge succeeded — safe to clear the worktree tracking state now
originalCwd = null;
clearWorktreeOriginalCwd();
ctx.ui.notify(
[
`${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) {
try {
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
// Validate the worktree exists before attempting removal
const worktrees = listWorktrees(mainBase);
const wt = worktrees.find((w) => w.name === name);
@ -787,9 +765,9 @@ async function handleRemove(basePath, name, ctx) {
const prevCwd = process.cwd();
removeWorktree(mainBase, name, { deleteBranch: true });
// 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);
originalCwd = null;
clearWorktreeOriginalCwd();
}
ctx.ui.notify(
`${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) {
try {
const mainBase = originalCwd ?? basePath;
const mainBase = getWorktreeOriginalCwd() ?? basePath;
const worktrees = listWorktrees(mainBase);
if (worktrees.length === 0) {
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 (originalCwd && process.cwd() !== prevCwd) {
if (getWorktreeOriginalCwd() && process.cwd() !== prevCwd) {
nudgeGitBranchCache(prevCwd);
originalCwd = null;
clearWorktreeOriginalCwd();
}
const lines = [];
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;
}