diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f6826617c..cf1f6c083 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,29 +17,25 @@ ChatGPT should call `open_workspace` once for a project folder: The result includes a `workspaceId`. All later file, search, edit, show-changes, and shell calls should reuse that same `workspaceId`. -ChatGPT may support automatic checkout recovery through optional host +ChatGPT may support automatic workspace recovery through optional host conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP conversation field. When that optional context is available, opening the same checkout project again in the same conversation can continue in the existing -workspace, and the context already provided for that reused checkout is not -repeated. The portable workflow remains the same: keep using the `workspaceId` -returned by `open_workspace` for later operations. Hosts without supported -conversation context receive a normal new workspace and continue with that -explicit `workspaceId` workflow. +workspace. Worktree mode similarly reuses the active managed worktree lease for +the same conversation, canonical Git repository, and base ref. The portable +workflow remains the same: keep using the `workspaceId` returned by +`open_workspace` for later operations. Hosts without supported conversation +context receive a normal new workspace and continue with that explicit +`workspaceId` workflow. The model receives actionable workspace instructions; automatic-reuse bookkeeping is not a model-facing choice. -Worktree mode is deliberately different: every call creates a new managed -worktree and a new workspace session with complete context, even for the same -path and base ref. - -The first successful open of a checkout provides complete instructions and -coding context. A repeated open that reuses the same checkout workspace does -not repeat the model-visible context, but the workspace UI continues to show the -complete details. Every new worktree establishes and returns its own complete -context, even when the same project was already opened in checkout or another -worktree. Opening checkout after a worktree therefore provides the checkout's -own context. +The first successful open of a checkout or managed worktree provides complete +instructions and coding context. A repeated open that reuses the same +conversation workspace does not repeat the model-visible context, but the +workspace UI continues to show the complete details. A different conversation, +base ref, or workspace mode establishes its own context. Opening checkout after +a worktree therefore still provides the checkout's own context. Do not call `open_workspace` again for the same checkout folder unless: @@ -80,15 +76,34 @@ Managed worktrees are created under: Worktree mode requires a Git repository with at least one commit. It starts from `HEAD` unless `baseRef` is provided. -Each worktree-mode call creates a new managed worktree and returns a new -`workspaceId`. Reuse that ID for work inside that worktree; call -`open_workspace` in worktree mode again only when another isolated worktree is -actually required. +With supported conversation metadata, the first worktree-mode open creates one +managed worktree lease for the conversation, canonical Git repository, and base +ref. Repeated or concurrent opens reuse that same `workspaceId`, including after +a DevSpace restart. A different conversation or base ref receives a separate +managed worktree. After `close_workspace` releases a terminal lease, the next +open creates a fresh worktree. Hosts without supported conversation metadata +continue to receive a fresh worktree for each open, so callers should still +reuse the returned `workspaceId` directly whenever possible. Uncommitted source checkout changes are not copied into the managed worktree. DevSpace reports when the source checkout was dirty so the model can decide how to proceed with the user. +## Release A Terminal Workspace + +Call `close_workspace` once only when work in that workspace is genuinely +terminal and no DevSpace process session is still running for it. Closing the +workspace releases its durable DevSpace lease and makes that `workspaceId` +non-reusable. + +`close_workspace` does not delete a managed worktree, branch, commit, or project +file. Worktree removal remains a separate repository-policy operation that can +apply Git cleanliness, integration, process, lock, and other safety checks. + +Do not infer terminal state from a response ending, MCP transport closure, +server restart, workspace age, or filesystem mtime. Paused or resumable work +must keep its lease active. + ## Project Instructions When a workspace opens, DevSpace loads root-level instruction files: @@ -153,6 +168,7 @@ sessions for that workspace. The Claude surface exposes these tool names: - `open_workspace` +- `close_workspace` - `read` - `write` - `edit` @@ -162,6 +178,7 @@ The Claude surface exposes these tool names: DevSpace uses the Codex-style surface by default. It exposes: - `open_workspace` +- `close_workspace` - `read` - `apply_patch` - `exec_command` diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..07e32d7b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,8 +83,22 @@ rejected so spelling mistakes cannot silently alter behavior. | Value | Tool surface | | --- | --- | -| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. | -| `claude` | `open_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. | +| `codex` | Default. `open_workspace`, `close_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. | +| `claude` | `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. | + +`close_workspace` is an explicit lease release, not a deletion primitive. Call +it only when work in that workspace is genuinely terminal. It does not remove a +managed worktree, branch, commit, or project files, and a released workspace ID +cannot be reused. A dropped MCP transport, server restart, age, or filesystem +mtime does not imply release. + +When the host supplies supported conversation metadata, a managed worktree is +leased to that conversation by canonical Git repository and base ref. Repeated +or concurrent opens reuse the active lease across DevSpace restarts instead of +creating duplicate worktrees. Releasing the workspace removes that binding, so +the next open creates a fresh managed worktree. Hosts without conversation +metadata keep the explicit `workspaceId` workflow and do not infer reuse from +age or filesystem state. The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses its shell tool with programs such as `rg`, `find`, and `ls` when it needs those diff --git a/src/db/migrations.ts b/src/db/migrations.ts index df192caa0..08bc17f99 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -37,6 +37,11 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 7, + name: "workspace-terminal-lifecycle", + up: migrateWorkspaceTerminalLifecycle, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -235,6 +240,30 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } +function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void { + // Interrupted legacy upgrades can have migration 1 recorded while the + // workspace tables are absent. Repair the baseline instead of recording v7 + // against a database that still cannot persist workspace lifecycle state. + if (!tableExists(sqlite, "workspace_sessions")) { + migrateWorkspaceState(sqlite); + } + + addColumnIfMissing(sqlite, "workspace_sessions", "terminal_at", "text"); + addColumnIfMissing(sqlite, "workspace_sessions", "terminal_reason", "text"); + sqlite.exec(` + create index if not exists workspace_sessions_lifecycle_idx + on workspace_sessions(status, mode, managed, id); + `); +} + +function tableExists(sqlite: Database.Database, table: string): boolean { + return Boolean( + sqlite + .prepare("select 1 from sqlite_master where type = 'table' and name = ? limit 1") + .get(table), + ); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index c16da8925..3ddb7ce80 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -13,10 +13,18 @@ export const workspaceSessions = sqliteTable( managed: text("managed").notNull().default("false"), createdAt: text("created_at").notNull(), lastUsedAt: text("last_used_at").notNull(), + terminalAt: text("terminal_at"), + terminalReason: text("terminal_reason"), }, (table) => [ index("workspace_sessions_root_idx").on(table.root, table.lastUsedAt), index("workspace_sessions_status_idx").on(table.status, table.lastUsedAt), + index("workspace_sessions_lifecycle_idx").on( + table.status, + table.mode, + table.managed, + table.id, + ), ], ); diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index 04986c9a5..976666c08 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -33,11 +33,10 @@ export interface ManagedWorktree { managed: boolean; } -export async function createManagedWorktree(input: { +export async function resolveManagedWorktreeSourceRoot(input: { sourcePath: string; - baseRef?: string; config: ServerConfig; -}): Promise { +}): Promise { const sourcePath = assertAllowedPath(input.sourcePath, input.config.allowedRoots); try { @@ -56,7 +55,15 @@ export async function createManagedWorktree(input: { ); } - const sourceRoot = await resolveGitRoot(sourcePath, input.config.allowedRoots); + return resolveGitRoot(sourcePath, input.config.allowedRoots); +} + +export async function createManagedWorktree(input: { + sourcePath: string; + baseRef?: string; + config: ServerConfig; +}): Promise { + const sourceRoot = await resolveManagedWorktreeSourceRoot(input); const baseRef = input.baseRef ?? "HEAD"; const baseSha = await resolveBaseCommit(sourceRoot, baseRef); const dirtySource = (await git(["status", "--porcelain=v1"], sourceRoot)).trim().length > 0; @@ -90,6 +97,12 @@ export async function createManagedWorktree(input: { }; } +export async function removeManagedWorktree( + worktree: Pick, +): Promise { + await git(["worktree", "remove", worktree.path], worktree.sourceRoot); +} + async function resolveGitRoot(path: string, allowedRoots: string[]): Promise { try { const output = await git(["rev-parse", "--show-toplevel"], path); diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf5..33826805b 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,6 +47,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 4, name: "workspace-conversation-bindings" }, { version: 5, name: "local-agent-structured-errors" }, { version: 6, name: "local-agent-effort-rename" }, + { version: 7, name: "workspace-terminal-lifecycle" }, ]); } finally { database.close(); diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f414df193..a48e40f31 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -281,6 +281,13 @@ export class ProcessSessionManager { if (session.running) session.process?.kill("SIGTERM"); } + hasRunningForWorkspace(workspaceId: string): boolean { + for (const session of this.sessions.values()) { + if (session.workspaceId === workspaceId && session.running) return true; + } + return false; + } + shutdown(): void { for (const session of this.sessions.values()) { if (session.cleanupTimer) clearTimeout(session.cleanupTimer); diff --git a/src/server.test.ts b/src/server.test.ts index 79c21a66c..be6dfe179 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -27,11 +27,11 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { }> = [ { mode: "claude", - expected: ["open_workspace", "read", "write", "edit", "bash", "show_changes"], + expected: ["open_workspace", "close_workspace", "read", "write", "edit", "bash", "show_changes"], }, { mode: "codex", - expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin", "show_changes"], + expected: ["open_workspace", "close_workspace", "read", "apply_patch", "exec_command", "write_stdin", "show_changes"], }, ]; diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 4fc21f221..bd4e34e47 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -22,7 +22,7 @@ import { textBlock, } from "./shared.js"; -const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for inspection, tests, builds, and other commands. Shell commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; +const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for inspection, tests, builds, and other commands. Shell commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope. When work in a workspace is genuinely terminal and no DevSpace command is still running for it, call ${toolNames.closeWorkspace} once to release its workspace lease. Do not release a workspace merely because a response, transport, or conversational turn is ending; paused or resumable work must remain active.`; export function claudeInstructions({ agents, diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 526e175bb..c89b03580 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -17,7 +17,7 @@ import { type CodexRegistration = (context: ToolRegistrationContext) => void; -const CODEX_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; +const CODEX_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope. When work in a workspace is genuinely terminal and no DevSpace command is still running for it, call ${toolNames.closeWorkspace} once to release its workspace lease. Do not release a workspace merely because a response, transport, or conversational turn is ending; paused or resumable work must remain active.`; export function codexInstructions(): string { return CODEX_INSTRUCTIONS; diff --git a/src/tool-surfaces/index.ts b/src/tool-surfaces/index.ts index f86a6e118..bae42e907 100644 --- a/src/tool-surfaces/index.ts +++ b/src/tool-surfaces/index.ts @@ -1,15 +1,30 @@ import type { ToolMode } from "../config.js"; +import { registerWorkspaceLifecycleTool } from "../workspace-lifecycle.js"; import { codexInstructions, registerCodexTools } from "./codex.js"; import { claudeInstructions, registerClaudeTools } from "./claude.js"; -import { type ToolSurface } from "./types.js"; +import { type ToolRegistrationContext, type ToolSurface } from "./types.js"; + +function registerWithWorkspaceLifecycle( + register: (context: ToolRegistrationContext) => void, +): (context: ToolRegistrationContext) => void { + return (context) => { + register(context); + registerWorkspaceLifecycleTool( + context.server, + context.config, + context.workspaces, + context.processSessions, + ); + }; +} const TOOL_SURFACES: Record = { claude: { - register: registerClaudeTools, + register: registerWithWorkspaceLifecycle(registerClaudeTools), instructions: claudeInstructions, }, codex: { - register: registerCodexTools, + register: registerWithWorkspaceLifecycle(registerCodexTools), instructions: codexInstructions, }, }; diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index a9d8131b8..723739913 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -7,6 +7,7 @@ export const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; export const toolNames = { openWorkspace: "open_workspace", + closeWorkspace: "close_workspace", read: "read", write: "write", edit: "edit", diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 45c45a14d..00f021ca9 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; @@ -82,7 +82,7 @@ test("a checkout without a conversation scope does not use conversation reuse", assert.notEqual(second.workspace.id, first.workspace.id); }); -test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { +test("a conversation reuses its managed worktree without replacing the reusable checkout", async (t) => { const { project, registry } = await fixture(t, { git: true }); const worktreeInput = { path: project, mode: "worktree" as const }; @@ -95,8 +95,9 @@ test("worktree requests remain fresh without replacing the reusable checkout", a }); const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); - assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); + assert.equal(secondWorktree.workspace.id, firstWorktree.workspace.id); + assert.equal(secondWorktree.workspace.root, firstWorktree.workspace.root); + assert.equal(secondWorktree.workspaceReused, true); assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); }); @@ -115,7 +116,39 @@ test("a worktree-first conversation creates and then reuses its checkout", async assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); }); -test("concurrent worktree opens remain fresh and return complete context", async (t) => { +test("different conversations receive separate managed worktree leases", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const input = { path: project, mode: "worktree" as const }; + + const first = await registry.openWorkspace(input, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(input, { conversationScopeId: "chat-2" }); + + assert.notEqual(second.workspace.id, first.workspace.id); + assert.notEqual(second.workspace.root, first.workspace.root); +}); + +test("managed worktree lease uses the canonical Git root across repository subpaths", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const nested = join(project, "apps", "api"); + await mkdir(nested, { recursive: true }); + await writeFile(join(nested, "README.md"), "api\n"); + await git(project, ["add", "."]); + await git(project, ["commit", "-m", "Add nested project"]); + + const rootOpen = await registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + const nestedOpen = await registry.openWorkspace( + { path: nested, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + + assert.equal(nestedOpen.workspace.id, rootOpen.workspace.id); + assert.equal(nestedOpen.workspace.root, rootOpen.workspace.root); +}); + +test("concurrent worktree opens reuse one lease and return complete context", async (t) => { const { project, registry } = await fixture(t, { git: true }); const worktreeInput = { path: project, mode: "worktree" as const }; @@ -124,8 +157,8 @@ test("concurrent worktree opens remain fresh and return complete context", async registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), ]); - assert.notEqual(first.workspace.id, second.workspace.id); - assert.notEqual(first.workspace.root, second.workspace.root); + assert.equal(first.workspace.id, second.workspace.id); + assert.equal(first.workspace.root, second.workspace.root); assert.deepEqual( first.agentsFiles.map((file) => file.content), second.agentsFiles.map((file) => file.content), @@ -152,6 +185,110 @@ test("checkout reuse survives a registry restart", async (t) => { assert.equal(restored.workspace.id, first.workspace.id); }); +test("managed worktree reuse survives a registry restart", async (t) => { + const context = await fixture(t, { git: true }); + const input = { path: context.project, mode: "worktree" as const }; + const first = await context.registry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const restored = await restoredRegistry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + + assert.equal(restored.workspace.id, first.workspace.id); + assert.equal(restored.workspace.root, first.workspace.root); + assert.equal(restored.workspaceReused, true); +}); + +test("released managed worktree lease is replaced for the same conversation", async (t) => { + const context = await fixture(t, { git: true }); + const input = { path: context.project, mode: "worktree" as const }; + const first = await context.registry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + + const released = context.registry.releaseWorkspace(first.workspace.id); + assert.equal(released.status, "released"); + + const replacement = await context.registry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.notEqual(replacement.workspace.root, first.workspace.root); + assert.equal(replacement.workspaceReused, false); +}); + +test("missing managed worktree is reconciled before its conversation gets a replacement", async (t) => { + const context = await fixture(t, { git: true }); + const input = { path: context.project, mode: "worktree" as const }; + const first = await context.registry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + + await rm(first.workspace.root, { recursive: true, force: true }); + const replacement = await context.registry.openWorkspace(input, { + conversationScopeId: "chat-1", + }); + + assert.equal(context.store.getSession(first.workspace.id)?.status, "missing"); + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.notEqual(replacement.workspace.root, first.workspace.root); +}); + +test("failed managed worktree context initialization does not leave a lease or worktree", async (t) => { + const context = await fixture(t, { git: true }); + const agentsDir = join(context.project, ".devspace", "agents"); + await rm(agentsDir, { recursive: true, force: true }); + await writeFile(agentsDir, "not a directory\n"); + await git(context.project, ["add", "-A"]); + await git(context.project, ["commit", "-m", "Break agent directory"]); + + const before = await directoryNames(context.config.worktreeRoot); + await assert.rejects( + () => context.registry.openWorkspace( + { path: context.project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + /directory|ENOTDIR/i, + ); + + assert.deepEqual(await directoryNames(context.config.worktreeRoot), before); + assert.equal(context.store.listActiveManagedSessions().length, 0); +}); + +test("failed managed worktree conversation binding does not leave an active lease or worktree", async (t) => { + const context = await fixture(t, { git: true }); + const failingStore = new Proxy(context.store, { + get(target, property, receiver) { + if (property === "setConversationBinding") { + return () => { + throw new Error("simulated conversation binding failure"); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const registry = new WorkspaceRegistry(context.config, failingStore); + const before = await directoryNames(context.config.worktreeRoot); + + await assert.rejects( + () => registry.openWorkspace( + { path: context.project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + /simulated conversation binding failure/, + ); + + assert.deepEqual(await directoryNames(context.config.worktreeRoot), before); + assert.equal(context.store.listActiveManagedSessions().length, 0); +}); + test("a failed first context load does not consume bootstrap", async (t) => { const { project, registry } = await fixture(t); const agentsDir = join(project, ".devspace", "agents"); @@ -473,6 +610,25 @@ async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } +async function directoryNames(path: string): Promise { + try { + return (await readdir(path, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } +} + function checkoutTargetKey(project: string): string { return JSON.stringify(["checkout", project, null]); } diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts new file mode 100644 index 000000000..49010789f --- /dev/null +++ b/src/workspace-lifecycle.test.ts @@ -0,0 +1,350 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { loadConfig, type ServerConfig } from "./config.js"; +import { openDatabase } from "./db/client.js"; +import { ProcessSessionManager } from "./process-sessions.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; +import { + getManagedWorkspaceReconciliationStatus, + releaseWorkspaceLease, + requestManagedWorkspaceReconciliation, + runManagedWorkspaceReconciliationSweep, +} from "./workspace-lifecycle.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +interface LifecycleFixture { + root: string; + sourceRoot: string; + worktreeRoot: string; + stateDir: string; + config: ServerConfig; +} + +async function lifecycleFixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-lifecycle-test-")); + const sourceRoot = join(root, "project"); + const worktreeRoot = join(root, ".devspace", "worktrees"); + const stateDir = join(root, ".state"); + await mkdir(sourceRoot, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + + const config = loadConfig(writeTestDevspaceConfig(join(root, ".devspace-home"), { + server: { port: 1 }, + workspaces: { + allowedRoots: [root], + worktreeRoot, + }, + })); + + t.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + + return { root, sourceRoot, worktreeRoot, stateDir, config }; +} + +test("explicit release persists across restart and retains the managed worktree", async (t) => { + const fixture = await lifecycleFixture(t); + const managedRoot = join(fixture.worktreeRoot, "managed-retained"); + const retainedFile = join(managedRoot, "unique.txt"); + await mkdir(managedRoot); + await writeFile(retainedFile, "unique source remains\n"); + + const firstStore = new SqliteWorkspaceStore(fixture.stateDir); + firstStore.createSession({ + id: "ws_released", + root: managedRoot, + mode: "worktree", + sourceRoot: fixture.sourceRoot, + baseRef: "origin/main", + baseSha: "abc123", + managed: true, + }); + const firstRegistry = new WorkspaceRegistry(fixture.config, firstStore); + + const released = firstRegistry.releaseWorkspace("ws_released"); + assert.equal(released.status, "released"); + assert.equal(released.terminalReason, "explicit_release"); + assert.ok(released.terminalAt); + assert.equal((await stat(managedRoot)).isDirectory(), true); + assert.equal((await stat(retainedFile)).isFile(), true); + + const releasedAgain = firstRegistry.releaseWorkspace("ws_released"); + assert.equal(releasedAgain.status, "released"); + assert.equal(releasedAgain.terminalAt, released.terminalAt); + firstStore.close(); + + const secondStore = new SqliteWorkspaceStore(fixture.stateDir); + try { + const restored = secondStore.getSession("ws_released"); + assert.equal(restored?.status, "released"); + assert.equal(restored?.terminalAt, released.terminalAt); + + const secondRegistry = new WorkspaceRegistry(fixture.config, secondStore); + assert.throws( + () => secondRegistry.getWorkspace("ws_released"), + /is released and cannot be reused/, + ); + assert.equal((await stat(retainedFile)).isFile(), true); + } finally { + secondStore.close(); + } +}); + +test("restart keeps an existing managed workspace active until explicit release", async (t) => { + const fixture = await lifecycleFixture(t); + const managedRoot = join(fixture.worktreeRoot, "managed-active"); + await mkdir(managedRoot); + + const firstStore = new SqliteWorkspaceStore(fixture.stateDir); + firstStore.createSession({ + id: "ws_restart_active", + root: managedRoot, + mode: "worktree", + sourceRoot: fixture.sourceRoot, + baseRef: "origin/main", + baseSha: "abc123", + managed: true, + }); + firstStore.close(); + + const secondStore = new SqliteWorkspaceStore(fixture.stateDir); + try { + assert.equal(secondStore.getSession("ws_restart_active")?.status, "active"); + const restored = new WorkspaceRegistry(fixture.config, secondStore).getWorkspace( + "ws_restart_active", + ); + assert.equal(restored.id, "ws_restart_active"); + assert.equal(restored.root, managedRoot); + } finally { + secondStore.close(); + } +}); + +test("legacy lifecycle state is neither reusable nor explicit release authority", async (t) => { + const fixture = await lifecycleFixture(t); + const managedRoot = join(fixture.worktreeRoot, "legacy-state"); + await mkdir(managedRoot); + + const firstStore = new SqliteWorkspaceStore(fixture.stateDir); + firstStore.createSession({ + id: "ws_legacy_state", + root: managedRoot, + mode: "worktree", + sourceRoot: fixture.sourceRoot, + managed: true, + }); + firstStore.close(); + + const database = openDatabase(fixture.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set status = 'inactive' where id = ?") + .run("ws_legacy_state"); + } finally { + database.close(); + } + + const secondStore = new SqliteWorkspaceStore(fixture.stateDir); + try { + assert.equal(secondStore.getSession("ws_legacy_state")?.status, "unknown"); + const registry = new WorkspaceRegistry(fixture.config, secondStore); + assert.throws( + () => registry.getWorkspace("ws_legacy_state"), + /is unknown and cannot be reused/, + ); + assert.throws( + () => registry.releaseWorkspace("ws_legacy_state"), + /Unknown workspaceId/, + ); + assert.equal(secondStore.getSession("ws_legacy_state")?.status, "unknown"); + assert.equal((await stat(managedRoot)).isDirectory(), true); + } finally { + secondStore.close(); + } +}); + +test("managed session reconciliation is bounded and only terminalizes missing roots", async (t) => { + const fixture = await lifecycleFixture(t); + const store = new SqliteWorkspaceStore(fixture.stateDir); + try { + const activeRoot = join(fixture.worktreeRoot, "active-existing"); + await mkdir(activeRoot); + await writeFile(join(activeRoot, "work.txt"), "still active\n"); + + store.createSession({ + id: "ws_001_active", + root: activeRoot, + mode: "worktree", + sourceRoot: fixture.sourceRoot, + managed: true, + }); + store.createSession({ + id: "ws_002_missing", + root: join(fixture.worktreeRoot, "missing-2"), + mode: "worktree", + sourceRoot: fixture.sourceRoot, + managed: true, + }); + store.createSession({ + id: "ws_003_missing", + root: join(fixture.worktreeRoot, "missing-3"), + mode: "worktree", + sourceRoot: fixture.sourceRoot, + managed: true, + }); + + const registry = new WorkspaceRegistry(fixture.config, store); + const first = await registry.reconcileManagedWorktreeSessions({ limit: 2 }); + assert.equal(first.checked, 2); + assert.equal(first.reconciled, 1); + assert.equal(first.nextCursor, "ws_002_missing"); + assert.equal(store.getSession("ws_001_active")?.status, "active"); + assert.equal(store.getSession("ws_002_missing")?.status, "missing"); + assert.equal(store.getSession("ws_003_missing")?.status, "active"); + + const second = await registry.reconcileManagedWorktreeSessions({ + cursor: first.nextCursor, + limit: 2, + }); + assert.equal(second.checked, 1); + assert.equal(second.reconciled, 1); + assert.equal(second.nextCursor, undefined); + assert.equal(store.getSession("ws_003_missing")?.status, "missing"); + assert.equal((await stat(activeRoot)).isDirectory(), true); + } finally { + store.close(); + } +}); + +test("managed reconciliation sweep continues through every bounded page", async () => { + const calls: Array<{ cursor?: string; limit?: number }> = []; + const result = await runManagedWorkspaceReconciliationSweep({ + async reconcileManagedWorktreeSessions(input) { + calls.push({ ...input }); + assert.equal(input.limit, 128); + if (input.cursor === undefined) { + return { checked: 128, reconciled: 3, nextCursor: "ws_128" }; + } + if (input.cursor === "ws_128") { + return { checked: 128, reconciled: 2, nextCursor: "ws_256" }; + } + if (input.cursor === "ws_256") { + return { checked: 7, reconciled: 1 }; + } + throw new Error(`Unexpected reconciliation cursor: ${input.cursor}`); + }, + }); + + assert.deepEqual(calls, [ + { cursor: undefined, limit: 128 }, + { cursor: "ws_128", limit: 128 }, + { cursor: "ws_256", limit: 128 }, + ]); + assert.deepEqual(result, { + batches: 3, + checked: 263, + reconciled: 6, + }); +}); + +test("managed reconciliation keeps an inspectable owned task and completion result", async (t) => { + const fixture = await lifecycleFixture(t); + const store = new SqliteWorkspaceStore(fixture.stateDir); + try { + const registry = new WorkspaceRegistry(fixture.config, store); + const task = requestManagedWorkspaceReconciliation(fixture.config, registry); + assert.ok(task); + + const running = getManagedWorkspaceReconciliationStatus(registry); + assert.equal(running?.running, true); + assert.equal(running?.task, task); + assert.equal(requestManagedWorkspaceReconciliation(fixture.config, registry), task); + + await task; + + const completed = getManagedWorkspaceReconciliationStatus(registry); + assert.equal(completed?.running, false); + assert.equal(completed?.task, undefined); + assert.equal(completed?.lastError, undefined); + assert.deepEqual(completed?.lastResult, { + batches: 1, + checked: 0, + reconciled: 0, + }); + assert.ok(completed?.lastFullSweepAt); + } finally { + store.close(); + } +}); + +test("release fails closed when a DevSpace process owns the workspace", async () => { + let releaseCalls = 0; + const workspaces = { + releaseWorkspace: () => { + releaseCalls += 1; + throw new Error("release must not run while busy"); + }, + }; + const processSessions = { + hasRunningForWorkspace: (workspaceId: string) => workspaceId === "ws_busy", + }; + + assert.throws( + () => releaseWorkspaceLease(workspaces, processSessions, "ws_busy"), + /still owns a running process session/, + ); + assert.equal(releaseCalls, 0); +}); + +test("release rejects a nonterminal lifecycle result", () => { + const workspaces = { + releaseWorkspace: () => ({ + id: "ws_unknown", + root: "/tmp/devspace-unknown", + status: "unknown" as const, + mode: "worktree" as const, + managed: true, + createdAt: "2026-09-02T00:00:00.000Z", + lastUsedAt: "2026-09-02T00:00:00.000Z", + }), + }; + const processSessions = { + hasRunningForWorkspace: () => false, + }; + + assert.throws( + () => releaseWorkspaceLease(workspaces, processSessions, "ws_unknown"), + /did not reach an explicit terminal lifecycle state/, + ); +}); + +test("a process start publishes its workspace lease before the first async yield", async () => { + const manager = new ProcessSessionManager({ completedSessionTtlMs: 100 }); + const node = process.platform === "win32" + ? `"${process.execPath}"` + : JSON.stringify(process.execPath); + + try { + const pending = manager.start({ + workspaceId: "ws_race", + cwd: process.cwd(), + command: `${node} -e "setTimeout(() => {}, 1000)"`, + yieldTimeMs: 0, + }); + + assert.equal(manager.hasRunningForWorkspace("ws_race"), true); + assert.equal(manager.hasRunningForWorkspace("ws_other"), false); + + const snapshot = await pending; + assert.equal(snapshot.running, true); + assert.ok(snapshot.sessionId); + manager.terminate("ws_race", snapshot.sessionId); + } finally { + manager.shutdown(); + } +}); diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts new file mode 100644 index 000000000..13d86a086 --- /dev/null +++ b/src/workspace-lifecycle.ts @@ -0,0 +1,235 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import type { ServerConfig } from "./config.js"; +import { logEvent } from "./logger.js"; +import type { ProcessSessionManager } from "./process-sessions.js"; +import type { WorkspaceSession } from "./workspace-store.js"; +import { logToolCall, textBlock } from "./tool-surfaces/shared.js"; +import { workspaceIdDescription } from "./tool-surfaces/types.js"; +import type { + ManagedWorkspaceReconciliationResult, + WorkspaceRegistry, +} from "./workspaces.js"; + +const RECONCILIATION_BATCH_SIZE = 128; +const RECONCILIATION_RESCAN_MS = 6 * 60 * 60 * 1_000; + +export interface ManagedWorkspaceReconciliationStatus { + running: boolean; + lastFullSweepAt?: number; + lastResult?: ManagedWorkspaceReconciliationSweepResult; + lastError?: string; + task?: Promise; +} + +interface ManagedWorkspaceReconciler { + reconcileManagedWorktreeSessions(input: { + cursor?: string; + limit?: number; + }): Promise; +} + +export interface ManagedWorkspaceReconciliationSweepResult { + batches: number; + checked: number; + reconciled: number; +} + +type TerminalWorkspaceSession = WorkspaceSession & { + status: "released" | "missing"; +}; + +const reconciliationStates = new WeakMap< + WorkspaceRegistry, + ManagedWorkspaceReconciliationStatus +>(); + +function isTerminalWorkspaceSession( + session: WorkspaceSession, +): session is TerminalWorkspaceSession { + return session.status === "released" || session.status === "missing"; +} + +export function releaseWorkspaceLease( + workspaces: Pick, + processSessions: Pick, + workspaceId: string, +): TerminalWorkspaceSession { + // Keep the running-process check and lifecycle transition synchronous with + // respect to the Node event loop: no await may appear between these calls. + // ProcessSessionManager.start() records its session before its first yield, + // so a concurrent start either wins and blocks close or sees a terminal ID. + if (processSessions.hasRunningForWorkspace(workspaceId)) { + throw new Error( + `Workspace ${workspaceId} still owns a running process session. Terminate or finish it before closing the workspace.`, + ); + } + + const session = workspaces.releaseWorkspace(workspaceId); + if (!isTerminalWorkspaceSession(session)) { + throw new Error( + `Workspace ${workspaceId} did not reach an explicit terminal lifecycle state.`, + ); + } + return session; +} + +export function registerWorkspaceLifecycleTool( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + processSessions: ProcessSessionManager, +): void { + requestManagedWorkspaceReconciliation(config, workspaces); + + server.registerTool( + "close_workspace", + { + title: "Close workspace", + description: + "Release a DevSpace workspace lease only when work in that workspace is genuinely terminal. This does not delete a managed worktree, branch, commit, or project files. A running DevSpace process session blocks release. The released workspaceId cannot be reused; open the project again if more work is needed later.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + }, + outputSchema: { + workspaceId: z.string(), + root: z.string(), + mode: z.enum(["checkout", "worktree"]), + managed: z.boolean(), + status: z.enum(["released", "missing"]), + terminalAt: z.string().optional(), + terminalReason: z.string().optional(), + worktreeRetained: z.boolean(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + const session = releaseWorkspaceLease(workspaces, processSessions, workspaceId); + const worktreeRetained = session.mode === "worktree" && session.managed; + const result = { + workspaceId: session.id, + root: session.root, + mode: session.mode, + managed: session.managed, + status: session.status, + terminalAt: session.terminalAt, + terminalReason: session.terminalReason, + worktreeRetained, + } as const; + const content = [ + textBlock( + worktreeRetained + ? `Released workspace ${session.id}. The managed worktree, branch, commits, and files were retained. Separate Git/process/lock/integration proof is still required before worktree removal.` + : `Released workspace ${session.id}. No project files, branch, or commits were deleted.`, + ), + ]; + + logToolCall(config, { + tool: "close_workspace", + workspaceId: session.id, + path: session.root, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: result, + }; + }, + ); +} + +export async function runManagedWorkspaceReconciliationSweep( + workspaces: ManagedWorkspaceReconciler, +): Promise { + let cursor: string | undefined; + let batches = 0; + let checked = 0; + let reconciled = 0; + + do { + const previousCursor = cursor; + const result = await workspaces.reconcileManagedWorktreeSessions({ + cursor, + limit: RECONCILIATION_BATCH_SIZE, + }); + batches += 1; + checked += result.checked; + reconciled += result.reconciled; + cursor = result.nextCursor; + + if (cursor !== undefined && cursor === previousCursor) { + throw new Error( + `Managed workspace reconciliation did not advance past cursor ${cursor}.`, + ); + } + + if (cursor !== undefined) { + // Each page is strictly bounded. Yield before the next page so thousands + // of stale rows cannot monopolize the event loop during startup/recovery. + await new Promise((resolve) => setImmediate(resolve)); + } + } while (cursor !== undefined); + + return { batches, checked, reconciled }; +} + +export function getManagedWorkspaceReconciliationStatus( + workspaces: WorkspaceRegistry, +): Readonly | undefined { + const state = reconciliationStates.get(workspaces); + return state ? { ...state } : undefined; +} + +export function requestManagedWorkspaceReconciliation( + config: ServerConfig, + workspaces: WorkspaceRegistry, +): Promise | undefined { + const state = reconciliationStates.get(workspaces) ?? { + running: false, + }; + reconciliationStates.set(workspaces, state); + + if (state.running) return state.task; + if ( + state.lastFullSweepAt !== undefined && + Date.now() - state.lastFullSweepAt < RECONCILIATION_RESCAN_MS + ) { + return; + } + + state.running = true; + state.lastError = undefined; + const task = runManagedWorkspaceReconciliationSweep(workspaces) + .then((result) => { + state.lastResult = result; + state.lastFullSweepAt = Date.now(); + if (result.reconciled > 0) { + logEvent(config.logging, "info", "workspace_sessions_reconciled", { + checked: result.checked, + reconciled: result.reconciled, + batches: result.batches, + batchSize: RECONCILIATION_BATCH_SIZE, + }); + } + }) + .catch((error: unknown) => { + state.lastError = error instanceof Error ? error.message : String(error); + logEvent(config.logging, "warn", "workspace_session_reconciliation_failed", { + error: state.lastError, + }); + }) + .finally(() => { + state.running = false; + state.task = undefined; + }); + state.task = task; + return task; +} diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 88a70e2e5..94bd00b3a 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, asc, eq, gt } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceConversationBindings, @@ -8,11 +8,13 @@ import { } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; +export type WorkspaceSessionStatus = "active" | "released" | "missing" | "unknown"; +type TerminalWorkspaceSessionStatus = "released" | "missing"; export interface WorkspaceSession { id: string; root: string; - status: string; + status: WorkspaceSessionStatus; mode: WorkspaceMode; sourceRoot?: string; baseRef?: string; @@ -20,6 +22,8 @@ export interface WorkspaceSession { managed: boolean; createdAt: string; lastUsedAt: string; + terminalAt?: string; + terminalReason?: string; } export interface WorkspaceConversationBinding { @@ -42,6 +46,12 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + releaseSession(id: string, reason?: string): WorkspaceSession | undefined; + markSessionMissing(id: string, reason?: string): WorkspaceSession | undefined; + listActiveManagedSessions(input?: { + afterId?: string; + limit?: number; + }): WorkspaceSession[]; getConversationBinding( conversationScopeId: string, targetKey: string, @@ -53,6 +63,7 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeId: string, targetKey: string): void; deleteConversationBinding(conversationScopeId: string, targetKey: string): void; + deleteConversationBindingsForWorkspace(workspaceSessionId: string): void; close?(): void; } @@ -99,6 +110,8 @@ export class SqliteWorkspaceStore implements WorkspaceStore { managed: String(session.managed), createdAt: session.createdAt, lastUsedAt: session.lastUsedAt, + terminalAt: null, + terminalReason: null, }) .run(); @@ -119,10 +132,52 @@ export class SqliteWorkspaceStore implements WorkspaceStore { this.database.db .update(workspaceSessions) .set({ lastUsedAt: new Date().toISOString() }) - .where(eq(workspaceSessions.id, id)) + .where( + and( + eq(workspaceSessions.id, id), + eq(workspaceSessions.status, "active"), + ), + ) .run(); } + releaseSession(id: string, reason = "explicit_release"): WorkspaceSession | undefined { + const session = this.transitionSession(id, "released", reason); + return isExplicitTerminalSession(session) ? session : undefined; + } + + markSessionMissing(id: string, reason = "managed_worktree_missing"): WorkspaceSession | undefined { + const session = this.transitionSession(id, "missing", reason); + return isExplicitTerminalSession(session) ? session : undefined; + } + + listActiveManagedSessions( + input: { afterId?: string; limit?: number } = {}, + ): WorkspaceSession[] { + const limit = input.limit ?? 128; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 512) { + throw new Error("Managed workspace reconciliation limit must be an integer between 1 and 512."); + } + + const activeManaged = and( + eq(workspaceSessions.status, "active"), + eq(workspaceSessions.mode, "worktree"), + eq(workspaceSessions.managed, "true"), + ); + const condition = input.afterId + ? and(activeManaged, gt(workspaceSessions.id, input.afterId)) + : activeManaged; + const rows = this.database.db + .select() + .from(workspaceSessions) + .where(condition) + .orderBy(asc(workspaceSessions.id)) + .limit(limit) + .all(); + + return rows.map(rowToWorkspaceSession); + } + getConversationBinding( conversationScopeId: string, targetKey: string, @@ -201,10 +256,41 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + deleteConversationBindingsForWorkspace(workspaceSessionId: string): void { + this.database.db + .delete(workspaceConversationBindings) + .where(eq(workspaceConversationBindings.workspaceSessionId, workspaceSessionId)) + .run(); + } + close(): void { this.database.close(); } + private transitionSession( + id: string, + status: TerminalWorkspaceSessionStatus, + reason: string, + ): WorkspaceSession | undefined { + const now = new Date().toISOString(); + const row = this.database.db + .update(workspaceSessions) + .set({ + status, + terminalAt: now, + terminalReason: reason, + }) + .where( + and( + eq(workspaceSessions.id, id), + eq(workspaceSessions.status, "active"), + ), + ) + .returning() + .get(); + + return row ? rowToWorkspaceSession(row) : this.getSession(id); + } } export function createWorkspaceStore(stateDir: string): WorkspaceStore { @@ -215,7 +301,7 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { return { id: row.id, root: row.root, - status: row.status, + status: workspaceSessionStatus(row.status), mode: row.mode === "worktree" ? "worktree" : "checkout", sourceRoot: row.sourceRoot ?? undefined, baseRef: row.baseRef ?? undefined, @@ -223,9 +309,26 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { managed: row.managed === "true", createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, + terminalAt: row.terminalAt ?? undefined, + terminalReason: row.terminalReason ?? undefined, }; } +function workspaceSessionStatus(status: string): WorkspaceSessionStatus { + if (status === "active" || status === "released" || status === "missing") { + return status; + } + // Legacy or unexpected states are never treated as an active reusable lease, + // but they also do not constitute explicit release authority for GC. + return "unknown"; +} + +function isExplicitTerminalSession( + session: WorkspaceSession | undefined, +): session is WorkspaceSession & { status: TerminalWorkspaceSessionStatus } { + return session?.status === "released" || session?.status === "missing"; +} + function rowToWorkspaceConversationBinding( row: WorkspaceConversationBindingRow, ): WorkspaceConversationBinding { diff --git a/src/workspaces.ts b/src/workspaces.ts index 307626489..37ee80a60 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -3,13 +3,18 @@ import type { Stats } from "node:fs"; import type { WorkspaceConversationBinding, WorkspaceMode, + WorkspaceSession, WorkspaceStore, } from "./workspace-store.js"; import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; -import { createManagedWorktree } from "./git-worktrees.js"; +import { + createManagedWorktree, + removeManagedWorktree, + resolveManagedWorktreeSourceRoot, +} from "./git-worktrees.js"; import { AccessDeniedError, assertAllowedPath, @@ -82,6 +87,12 @@ export interface OpenWorkspaceOptions { conversationScopeId?: string; } +export interface ManagedWorkspaceReconciliationResult { + checked: number; + reconciled: number; + nextCursor?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -91,6 +102,7 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); private readonly pendingCheckoutOpens = new Map>(); + private readonly pendingWorktreeOpens = new Map>(); constructor( private readonly config: ServerConfig, @@ -107,15 +119,46 @@ export class WorkspaceRegistry { return this.openNewWorkspace(workspaceInput); } - const projectKey = await this.conversationProjectKey(workspaceInput); const mode = workspaceInput.mode ?? "checkout"; + const projectKey = mode === "worktree" + ? await canonicalPath( + await resolveManagedWorktreeSourceRoot({ + sourcePath: workspaceInput.path, + config: this.config, + }), + ) + : await this.conversationProjectKey(workspaceInput); if (mode === "worktree") { - const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); - return { - ...context, - // A new worktree always has its own workspace-specific context. - includeBootstrapContext: true, - }; + const targetKey = this.conversationWorktreeTargetKey( + projectKey, + workspaceInput.baseRef, + ); + const operationKey = JSON.stringify([conversationScopeId, targetKey]); + const pending = this.pendingWorktreeOpens.get(operationKey); + if (pending) { + const context = await pending; + return { + ...context, + workspaceReused: true, + includeBootstrapContext: false, + }; + } + + const open = this.openConversationWorktree( + workspaceInput, + conversationScopeId, + targetKey, + projectKey, + ); + this.pendingWorktreeOpens.set(operationKey, open); + + try { + return await open; + } finally { + if (this.pendingWorktreeOpens.get(operationKey) === open) { + this.pendingWorktreeOpens.delete(operationKey); + } + } } const targetKey = this.conversationCheckoutTargetKey(projectKey); @@ -190,6 +233,80 @@ export class WorkspaceRegistry { }; } + private async openConversationWorktree( + input: OpenWorkspaceInput, + conversationScopeId: string, + targetKey: string, + projectKey: string, + ): Promise { + const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); + if (binding) { + const reusableWorkspace = await this.findReusableWorktreeWorkspace( + binding, + projectKey, + input.baseRef, + ); + + if (reusableWorkspace) { + const context = await this.reusedWorkspaceContext(reusableWorkspace); + this.store?.touchConversationBinding(conversationScopeId, targetKey); + return { + ...context, + includeBootstrapContext: false, + }; + } + + this.workspaces.delete(binding.workspaceSessionId); + this.store?.deleteConversationBinding(conversationScopeId, targetKey); + } + + const context = await this.openWorktreeWorkspace(input.path, input.baseRef); + try { + this.store?.setConversationBinding({ + conversationScopeId, + targetKey, + workspaceSessionId: context.workspace.id, + }); + } catch (error) { + await this.cleanupFailedConversationWorktree(context.workspace, error); + } + return { + ...context, + includeBootstrapContext: true, + }; + } + + private async cleanupFailedConversationWorktree( + workspace: Workspace, + error: unknown, + ): Promise { + this.workspaces.delete(workspace.id); + const worktree = workspace.worktree; + const sourceRoot = workspace.sourceRoot; + if (!worktree?.managed || !sourceRoot) throw error; + + try { + await removeManagedWorktree({ sourceRoot, path: worktree.path }); + const transitioned = this.store?.markSessionMissing( + workspace.id, + "managed_worktree_open_failed", + ); + if (transitioned && transitioned.status !== "missing") { + throw new Error( + `Failed managed worktree ${workspace.id} did not reach missing state after cleanup.`, + ); + } + this.store?.deleteConversationBindingsForWorkspace(workspace.id); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Managed worktree conversation binding failed and cleanup did not complete: ${workspace.root}`, + ); + } + + throw error; + } + private async findReusableCheckoutWorkspace( binding: WorkspaceConversationBinding, ): Promise { @@ -219,6 +336,71 @@ export class WorkspaceRegistry { return workspace; } + private async findReusableWorktreeWorkspace( + binding: WorkspaceConversationBinding, + projectKey: string, + baseRef: string | undefined, + ): Promise { + const session = this.store?.getSession(binding.workspaceSessionId); + if ( + !session || + session.status !== "active" || + session.mode !== "worktree" || + !session.managed || + !session.sourceRoot || + (session.baseRef ?? "HEAD") !== (baseRef ?? "HEAD") + ) { + return undefined; + } + if ((await canonicalPath(session.sourceRoot)) !== projectKey) return undefined; + + let root: string; + try { + root = this.assertWorkspaceRootAllowed( + session.root, + session.mode, + session.sourceRoot, + ); + const rootStats = await stat(root); + if (!rootStats.isDirectory()) { + this.markManagedWorkspaceMissing(session.id); + return undefined; + } + } catch (error) { + if ( + error instanceof AccessDeniedError || + (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) + ) { + if (!(error instanceof AccessDeniedError)) { + this.markManagedWorkspaceMissing(session.id); + } + return undefined; + } + + throw error; + } + + const workspace = this.getWorkspace(binding.workspaceSessionId); + if ( + workspace.mode !== "worktree" || + !workspace.worktree?.managed || + workspace.root !== root + ) { + return undefined; + } + return workspace; + } + + private markManagedWorkspaceMissing(workspaceId: string): void { + const transitioned = this.store?.markSessionMissing( + workspaceId, + "managed_worktree_root_missing", + ); + if (transitioned?.status !== "missing") return; + this.workspaces.delete(workspaceId); + this.store?.deleteConversationBindingsForWorkspace(workspaceId); + } + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { const path = assertAllowedPath(input.path, this.config.allowedRoots); return canonicalPath(path); @@ -228,6 +410,13 @@ export class WorkspaceRegistry { return JSON.stringify(["checkout", projectKey, null]); } + private conversationWorktreeTargetKey( + projectKey: string, + baseRef: string | undefined, + ): string { + return JSON.stringify(["worktree", projectKey, baseRef ?? "HEAD"]); + } + private async reusedWorkspaceContext(workspace: Workspace): Promise { workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); @@ -255,6 +444,11 @@ export class WorkspaceRegistry { `Unknown workspaceId: ${workspaceId}. Open the target project or worktree again and continue with the new workspaceId.`, ); } + if (session.status !== "active") { + throw new Error( + `Workspace ${workspaceId} is ${session.status} and cannot be reused. Open the target project or worktree again and continue with the new workspaceId.`, + ); + } const root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); const restoredWorkspace: Workspace = { @@ -283,6 +477,72 @@ export class WorkspaceRegistry { return restoredWorkspace; } + releaseWorkspace(workspaceId: string, reason = "explicit_release"): WorkspaceSession { + if (!this.store) { + throw new Error("Workspace lifecycle persistence is unavailable."); + } + + const session = this.store.releaseSession(workspaceId, reason); + if (!session) { + throw new Error(`Unknown workspaceId: ${workspaceId}.`); + } + if (session.status === "active") { + throw new Error(`Workspace ${workspaceId} could not be released safely.`); + } + + this.workspaces.delete(workspaceId); + this.store.deleteConversationBindingsForWorkspace(workspaceId); + return session; + } + + async reconcileManagedWorktreeSessions(input: { + cursor?: string; + limit?: number; + } = {}): Promise { + if (!this.store) return { checked: 0, reconciled: 0 }; + + const limit = input.limit ?? 128; + const sessions = this.store.listActiveManagedSessions({ + afterId: input.cursor, + limit, + }); + let reconciled = 0; + + for (const session of sessions) { + let missing = false; + try { + const rootStats = await stat(session.root); + missing = !rootStats.isDirectory(); + } catch (error) { + if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + missing = true; + } else { + continue; + } + } + + if (!missing) continue; + const transitioned = this.store.markSessionMissing( + session.id, + "managed_worktree_root_missing", + ); + if (transitioned?.status === "missing") { + this.workspaces.delete(session.id); + this.store.deleteConversationBindingsForWorkspace(session.id); + reconciled += 1; + } + } + + return { + checked: sessions.length, + reconciled, + nextCursor: + sessions.length === limit + ? sessions[sessions.length - 1]?.id + : undefined, + }; + } + resolvePath(workspace: Workspace, inputPath: string): string { const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]); if (!isPathInsideRoot(absolutePath, workspace.root)) { @@ -342,12 +602,24 @@ export class WorkspaceRegistry { config: this.config, }); - return this.createWorkspaceContext({ - root: worktree.path, - mode: "worktree", - sourceRoot: worktree.sourceRoot, - worktree, - }); + try { + return await this.createWorkspaceContext({ + root: worktree.path, + mode: "worktree", + sourceRoot: worktree.sourceRoot, + worktree, + }); + } catch (error) { + try { + await removeManagedWorktree(worktree); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Managed worktree initialization failed and Git refused cleanup: ${worktree.path}`, + ); + } + throw error; + } } private async createWorkspaceContext(input: { @@ -367,6 +639,9 @@ export class WorkspaceRegistry { activatedSkillDirs: new Set(), }; + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + this.store?.createSession({ id: workspace.id, root: workspace.root, @@ -377,8 +652,6 @@ export class WorkspaceRegistry { managed: workspace.worktree?.managed, }); this.workspaces.set(workspace.id, workspace); - const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); - const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); return { workspace,