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:
parent
5be5d6d438
commit
181a19ac65
5 changed files with 156 additions and 80 deletions
53
src/resources/extensions/sf/auto-runtime-state.js
Normal file
53
src/resources/extensions/sf/auto-runtime-state.js
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
src/resources/extensions/sf/worktree-session-state.js
Normal file
52
src/resources/extensions/sf/worktree-session-state.js
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue