From 2da45af1985b12d433a054b595c8416be9b672fe Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:13:24 +0300 Subject: [PATCH 01/35] feat(workspaces): persist terminal lifecycle state --- src/db/schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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, + ), ], ); From 8d1325c3e45e72208fdaf10e0354d94a3287f422 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:13:45 +0300 Subject: [PATCH 02/35] feat(workspaces): migrate lifecycle terminal metadata --- src/db/migrations.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index df192caa0..515f582cc 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,15 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } +function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void { + 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 addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", From f9ce7ec0e4b97eddd2368d3f0fde4dc757231cd4 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:14:18 +0300 Subject: [PATCH 03/35] feat(workspaces): add atomic release lifecycle --- src/workspace-store.ts | 98 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 88a70e2e5..7f8ceb506 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,12 @@ import { } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; +export type WorkspaceSessionStatus = "active" | "released" | "missing"; export interface WorkspaceSession { id: string; root: string; - status: string; + status: WorkspaceSessionStatus; mode: WorkspaceMode; sourceRoot?: string; baseRef?: string; @@ -20,6 +21,8 @@ export interface WorkspaceSession { managed: boolean; createdAt: string; lastUsedAt: string; + terminalAt?: string; + terminalReason?: string; } export interface WorkspaceConversationBinding { @@ -42,6 +45,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 +62,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 +109,8 @@ export class SqliteWorkspaceStore implements WorkspaceStore { managed: String(session.managed), createdAt: session.createdAt, lastUsedAt: session.lastUsedAt, + terminalAt: null, + terminalReason: null, }) .run(); @@ -119,10 +131,50 @@ 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 { + return this.transitionSession(id, "released", reason); + } + + markSessionMissing(id: string, reason = "managed_worktree_missing"): WorkspaceSession | undefined { + return this.transitionSession(id, "missing", reason); + } + + 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 +253,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: Exclude, + 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 +298,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 +306,16 @@ 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 === "released" || status === "missing") return status; + return "active"; +} + function rowToWorkspaceConversationBinding( row: WorkspaceConversationBindingRow, ): WorkspaceConversationBinding { From 738a122861874f9c6a91ea249f204456ffb3f2d2 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:14:59 +0300 Subject: [PATCH 04/35] feat(workspaces): expose running process lease guard --- src/process-sessions.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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); From de857d7fe03b4600852254fcbff7e89992352713 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:16:09 +0300 Subject: [PATCH 05/35] feat(workspaces): release and reconcile managed leases --- src/workspaces.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/workspaces.ts b/src/workspaces.ts index 307626489..e40e8808f 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -3,6 +3,7 @@ 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"; @@ -82,6 +83,12 @@ export interface OpenWorkspaceOptions { conversationScopeId?: string; } +export interface ManagedWorkspaceReconciliationResult { + checked: number; + reconciled: number; + nextCursor?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -255,6 +262,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 +295,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)) { From 279658bc8e65f55a95023fe9ee4e7e03796e72b4 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:16:50 +0300 Subject: [PATCH 06/35] feat(workspaces): add explicit close and bounded reconciliation --- src/workspace-lifecycle.ts | 133 +++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/workspace-lifecycle.ts diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts new file mode 100644 index 000000000..93d9283cc --- /dev/null +++ b/src/workspace-lifecycle.ts @@ -0,0 +1,133 @@ +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 { ProcessSessionManager } from "./process-sessions.js"; +import { logToolCall, textBlock } from "./tool-surfaces/shared.js"; +import { workspaceIdDescription } from "./tool-surfaces/types.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +const RECONCILIATION_BATCH_SIZE = 128; +const RECONCILIATION_INTERVAL_MS = 5 * 60 * 1_000; + +export function registerWorkspaceLifecycleTool( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + processSessions: ProcessSessionManager, +): void { + 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(); + + // Keep the running-process check and lifecycle transition synchronous with + // respect to the Node event loop: no await may appear between these calls. + // processSessions.start() records the session before its first yield, so a + // concurrent start either wins and blocks close or sees the 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); + 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 function startManagedWorkspaceReconciliation( + config: ServerConfig, + workspaces: WorkspaceRegistry, +): () => void { + let cursor: string | undefined; + let running = false; + + const run = async (): Promise => { + if (running) return; + running = true; + try { + const result = await workspaces.reconcileManagedWorktreeSessions({ + cursor, + limit: RECONCILIATION_BATCH_SIZE, + }); + cursor = result.nextCursor; + if (result.reconciled > 0) { + logEvent(config.logging, "info", "workspace_sessions_reconciled", { + checked: result.checked, + reconciled: result.reconciled, + batchSize: RECONCILIATION_BATCH_SIZE, + }); + } + } catch (error) { + logEvent(config.logging, "warn", "workspace_session_reconciliation_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + running = false; + } + }; + + void run(); + const timer = setInterval(() => { + void run(); + }, RECONCILIATION_INTERVAL_MS); + timer.unref(); + + return () => clearInterval(timer); +} From a7def9da73ca8e7273cbf2ea40a0408701a56489 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:17:33 +0300 Subject: [PATCH 07/35] feat(workspaces): expose close workspace tool --- src/tool-surfaces/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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, }, }; From c173df1814ad5db765afa86f7205295b187ad4ac Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:18:08 +0300 Subject: [PATCH 08/35] perf(workspaces): make reconciliation event-driven and bounded --- src/workspace-lifecycle.ts | 74 +++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 93d9283cc..1b75baa76 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -2,13 +2,21 @@ 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 { ProcessSessionManager } from "./process-sessions.js"; +import type { ProcessSessionManager } from "./process-sessions.js"; import { logToolCall, textBlock } from "./tool-surfaces/shared.js"; import { workspaceIdDescription } from "./tool-surfaces/types.js"; -import { WorkspaceRegistry } from "./workspaces.js"; +import type { WorkspaceRegistry } from "./workspaces.js"; const RECONCILIATION_BATCH_SIZE = 128; -const RECONCILIATION_INTERVAL_MS = 5 * 60 * 1_000; +const RECONCILIATION_RESCAN_MS = 6 * 60 * 60 * 1_000; + +interface ReconciliationState { + cursor?: string; + running: boolean; + lastFullSweepAt?: number; +} + +const reconciliationStates = new WeakMap(); export function registerWorkspaceLifecycleTool( server: McpServer, @@ -16,6 +24,8 @@ export function registerWorkspaceLifecycleTool( workspaces: WorkspaceRegistry, processSessions: ProcessSessionManager, ): void { + requestManagedWorkspaceReconciliation(config, workspaces); + server.registerTool( "close_workspace", { @@ -91,22 +101,35 @@ export function registerWorkspaceLifecycleTool( ); } -export function startManagedWorkspaceReconciliation( +export function requestManagedWorkspaceReconciliation( config: ServerConfig, workspaces: WorkspaceRegistry, -): () => void { - let cursor: string | undefined; - let running = false; +): void { + const state = reconciliationStates.get(workspaces) ?? { + running: false, + }; + reconciliationStates.set(workspaces, state); - const run = async (): Promise => { - if (running) return; - running = true; - try { - const result = await workspaces.reconcileManagedWorktreeSessions({ - cursor, - limit: RECONCILIATION_BATCH_SIZE, - }); - cursor = result.nextCursor; + if (state.running) return; + if ( + state.cursor === undefined && + state.lastFullSweepAt !== undefined && + Date.now() - state.lastFullSweepAt < RECONCILIATION_RESCAN_MS + ) { + return; + } + + state.running = true; + void workspaces + .reconcileManagedWorktreeSessions({ + cursor: state.cursor, + limit: RECONCILIATION_BATCH_SIZE, + }) + .then((result) => { + state.cursor = result.nextCursor; + if (result.nextCursor === undefined) { + state.lastFullSweepAt = Date.now(); + } if (result.reconciled > 0) { logEvent(config.logging, "info", "workspace_sessions_reconciled", { checked: result.checked, @@ -114,20 +137,13 @@ export function startManagedWorkspaceReconciliation( batchSize: RECONCILIATION_BATCH_SIZE, }); } - } catch (error) { + }) + .catch((error: unknown) => { logEvent(config.logging, "warn", "workspace_session_reconciliation_failed", { error: error instanceof Error ? error.message : String(error), }); - } finally { - running = false; - } - }; - - void run(); - const timer = setInterval(() => { - void run(); - }, RECONCILIATION_INTERVAL_MS); - timer.unref(); - - return () => clearInterval(timer); + }) + .finally(() => { + state.running = false; + }); } From f2eddb077b25859b5f9f9b9f7c61288a47483de7 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:19:57 +0300 Subject: [PATCH 09/35] refactor(workspaces): isolate lease release guard for testing --- src/workspace-lifecycle.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 1b75baa76..01a4139c6 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -3,6 +3,7 @@ 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 { WorkspaceRegistry } from "./workspaces.js"; @@ -18,6 +19,24 @@ interface ReconciliationState { const reconciliationStates = new WeakMap(); +export function releaseWorkspaceLease( + workspaces: Pick, + processSessions: Pick, + workspaceId: string, +): WorkspaceSession { + // 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.`, + ); + } + + return workspaces.releaseWorkspace(workspaceId); +} + export function registerWorkspaceLifecycleTool( server: McpServer, config: ServerConfig, @@ -54,18 +73,7 @@ export function registerWorkspaceLifecycleTool( }, async ({ workspaceId }) => { const startedAt = performance.now(); - - // Keep the running-process check and lifecycle transition synchronous with - // respect to the Node event loop: no await may appear between these calls. - // processSessions.start() records the session before its first yield, so a - // concurrent start either wins and blocks close or sees the 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); + const session = releaseWorkspaceLease(workspaces, processSessions, workspaceId); const worktreeRetained = session.mode === "worktree" && session.managed; const result = { workspaceId: session.id, From 708eaa59fc0f82f8eb533bd6256785f1e7512eaf Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:20:29 +0300 Subject: [PATCH 10/35] test(workspaces): cover zero-loss lifecycle invariants --- src/workspace-lifecycle.test.ts | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/workspace-lifecycle.test.ts diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts new file mode 100644 index 000000000..b80f45ac2 --- /dev/null +++ b/src/workspace-lifecycle.test.ts @@ -0,0 +1,186 @@ +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 { ProcessSessionManager } from "./process-sessions.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; +import { releaseWorkspaceLease } 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("managed session reconciliation is bounded and only terminalizes missing roots", async (t) => { + const fixture = await lifecycleFixture(t); + const store = new SqliteWorkspaceStore(fixture.stateDir); + t.after(() => store.close()); + + 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); +}); + +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("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(); + } +}); From 91df46fc66267b037952dfbd963929dba945bded Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:21:26 +0300 Subject: [PATCH 11/35] fix(workspaces): preserve terminal status narrowing --- src/workspace-lifecycle.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 01a4139c6..977cc3fc8 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -17,13 +17,17 @@ interface ReconciliationState { lastFullSweepAt?: number; } +type TerminalWorkspaceSession = WorkspaceSession & { + status: "released" | "missing"; +}; + const reconciliationStates = new WeakMap(); export function releaseWorkspaceLease( workspaces: Pick, processSessions: Pick, workspaceId: string, -): WorkspaceSession { +): 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, @@ -34,7 +38,11 @@ export function releaseWorkspaceLease( ); } - return workspaces.releaseWorkspace(workspaceId); + const session = workspaces.releaseWorkspace(workspaceId); + if (session.status === "active") { + throw new Error(`Workspace ${workspaceId} could not be released safely.`); + } + return session as TerminalWorkspaceSession; } export function registerWorkspaceLifecycleTool( From e6744ef436f5e20bcdf3dd24c90e68268661357c Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:21:59 +0300 Subject: [PATCH 12/35] feat(workspaces): name terminal lifecycle tool --- src/tool-surfaces/types.ts | 1 + 1 file changed, 1 insertion(+) 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", From ea5bd3ab1a475416ce721d252b9c563763c6df2a Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:22:24 +0300 Subject: [PATCH 13/35] feat(workspaces): teach codex explicit lease release --- src/tool-surfaces/codex.ts | 67 ++++++++------------------------------ 1 file changed, 14 insertions(+), 53 deletions(-) diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 526e175bb..1205f1f06 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; @@ -218,9 +218,9 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { ); return processSessions.start({ workspaceId, + workspaceRoot: workspace.root, command: cmd, cwd, - workspaceRoot: workspace.root, tty, columns, rows, @@ -229,7 +229,6 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { }); }, ); - return processToolResponse(snapshot); }, ); @@ -239,50 +238,15 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Write to process", description: - "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", + "Write characters to a running process session or poll it for new output. Use this for interactive processes and commands that outlive the initial yield window.", inputSchema: { - workspaceId: z - .string() - .describe("Workspace identifier used to start the process."), - sessionId: z - .number() - .describe("Process session identifier returned by exec_command."), - chars: z - .string() - .optional() - .describe( - "Characters to write. Omit or pass an empty string to poll.", - ), - columns: z - .number() - .int() - .min(1) - .max(1_000) - .optional() - .describe("Resize a PTY to this width."), - rows: z - .number() - .int() - .min(1) - .max(1_000) - .optional() - .describe("Resize a PTY to this height."), - yieldTimeMs: z - .number() - .int() - .min(0) - .max(30_000) - .optional() - .describe( - "Milliseconds to wait for process output or completion. Defaults to 10000.", - ), - maxOutputTokens: z - .number() - .int() - .positive() - .max(100_000) - .optional() - .describe("Approximate output token budget. Defaults to 10000."), + workspaceId: z.string().describe(workspaceIdDescription), + sessionId: z.number().int().positive(), + chars: z.string().optional(), + columns: z.number().int().min(1).max(1_000).optional(), + rows: z.number().int().min(1).max(1_000).optional(), + yieldTimeMs: z.number().int().min(0).max(110_000).optional(), + maxOutputTokens: z.number().int().positive().max(100_000).optional(), }, outputSchema: processOutputSchema(), annotations: SHELL_TOOL_ANNOTATIONS, @@ -301,9 +265,8 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { config, { tool: "write_stdin", workspaceId }, startedAt, - async () => { - workspaces.getWorkspace(workspaceId); - return processSessions.write({ + () => + processSessions.write({ workspaceId, sessionId, chars, @@ -311,11 +274,9 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { rows, yieldTimeMs, maxOutputTokens, - }); - }, + }), ); - return processToolResponse(snapshot); }, ); -} +} \ No newline at end of file From d1aa80cf1b7657055f8960c5291baf51016d14ba Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:23:04 +0300 Subject: [PATCH 14/35] fix(workspaces): keep codex surface unchanged apart from lifecycle guidance --- src/tool-surfaces/codex.ts | 65 ++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 1205f1f06..c89b03580 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -218,9 +218,9 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { ); return processSessions.start({ workspaceId, - workspaceRoot: workspace.root, command: cmd, cwd, + workspaceRoot: workspace.root, tty, columns, rows, @@ -229,6 +229,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { }); }, ); + return processToolResponse(snapshot); }, ); @@ -238,15 +239,50 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Write to process", description: - "Write characters to a running process session or poll it for new output. Use this for interactive processes and commands that outlive the initial yield window.", + "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - sessionId: z.number().int().positive(), - chars: z.string().optional(), - columns: z.number().int().min(1).max(1_000).optional(), - rows: z.number().int().min(1).max(1_000).optional(), - yieldTimeMs: z.number().int().min(0).max(110_000).optional(), - maxOutputTokens: z.number().int().positive().max(100_000).optional(), + workspaceId: z + .string() + .describe("Workspace identifier used to start the process."), + sessionId: z + .number() + .describe("Process session identifier returned by exec_command."), + chars: z + .string() + .optional() + .describe( + "Characters to write. Omit or pass an empty string to poll.", + ), + columns: z + .number() + .int() + .min(1) + .max(1_000) + .optional() + .describe("Resize a PTY to this width."), + rows: z + .number() + .int() + .min(1) + .max(1_000) + .optional() + .describe("Resize a PTY to this height."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe( + "Milliseconds to wait for process output or completion. Defaults to 10000.", + ), + maxOutputTokens: z + .number() + .int() + .positive() + .max(100_000) + .optional() + .describe("Approximate output token budget. Defaults to 10000."), }, outputSchema: processOutputSchema(), annotations: SHELL_TOOL_ANNOTATIONS, @@ -265,8 +301,9 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { config, { tool: "write_stdin", workspaceId }, startedAt, - () => - processSessions.write({ + async () => { + workspaces.getWorkspace(workspaceId); + return processSessions.write({ workspaceId, sessionId, chars, @@ -274,9 +311,11 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { rows, yieldTimeMs, maxOutputTokens, - }), + }); + }, ); + return processToolResponse(snapshot); }, ); -} \ No newline at end of file +} From ce1ef6475a43ca656ab75f6daf7bda660b3ae23d Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:23:31 +0300 Subject: [PATCH 15/35] feat(workspaces): teach claude explicit lease release --- src/tool-surfaces/claude.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 8371e303263d62c8d006c4da515e4cc656263038 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:28:38 +0300 Subject: [PATCH 16/35] test(workspaces): include lifecycle tool in host surfaces --- src/server.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"], }, ]; From 55e2a23d7cf2c23c0622ec3440c1b6a0e37e9ba2 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:32:26 +0300 Subject: [PATCH 17/35] fix(workspaces): fail closed on legacy lifecycle states --- src/workspace-store.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 7f8ceb506..5da97835e 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -8,7 +8,8 @@ import { } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; -export type WorkspaceSessionStatus = "active" | "released" | "missing"; +export type WorkspaceSessionStatus = "active" | "released" | "missing" | "unknown"; +type TerminalWorkspaceSessionStatus = "released" | "missing"; export interface WorkspaceSession { id: string; @@ -266,7 +267,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { private transitionSession( id: string, - status: Exclude, + status: TerminalWorkspaceSessionStatus, reason: string, ): WorkspaceSession | undefined { const now = new Date().toISOString(); @@ -312,8 +313,12 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { } function workspaceSessionStatus(status: string): WorkspaceSessionStatus { - if (status === "released" || status === "missing") return status; - return "active"; + 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 rowToWorkspaceConversationBinding( From 40b5106c4e9c74c77cf404f8be52efdb16c7c3f0 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:33:07 +0300 Subject: [PATCH 18/35] fix(db): tolerate interrupted legacy workspace schema --- src/db/migrations.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 515f582cc..e062c4e6b 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -241,6 +241,12 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { } function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void { + // Legacy/interrupted test and recovery databases can claim migration 1 while + // lacking the workspace table entirely. Do not make unrelated stores fail to + // open in that state; a WorkspaceStore on such a database already has no + // usable workspace state and will fail closed independently. + if (!tableExists(sqlite, "workspace_sessions")) return; + addColumnIfMissing(sqlite, "workspace_sessions", "terminal_at", "text"); addColumnIfMissing(sqlite, "workspace_sessions", "terminal_reason", "text"); sqlite.exec(` @@ -249,6 +255,14 @@ function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void { `); } +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", From 14987cec04ce968bfebc6417ec3035e5ab717842 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:33:43 +0300 Subject: [PATCH 19/35] test(db): include workspace lifecycle migration --- src/oauth-store.test.ts | 1 + 1 file changed, 1 insertion(+) 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(); From f9af207f6b8bcfa49598812fc66d2ba69c71f51f Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:34:19 +0300 Subject: [PATCH 20/35] fix(workspaces): require explicit terminal authority from transitions --- src/workspace-store.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 5da97835e..94bd00b3a 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -142,11 +142,13 @@ export class SqliteWorkspaceStore implements WorkspaceStore { } releaseSession(id: string, reason = "explicit_release"): WorkspaceSession | undefined { - return this.transitionSession(id, "released", reason); + const session = this.transitionSession(id, "released", reason); + return isExplicitTerminalSession(session) ? session : undefined; } markSessionMissing(id: string, reason = "managed_worktree_missing"): WorkspaceSession | undefined { - return this.transitionSession(id, "missing", reason); + const session = this.transitionSession(id, "missing", reason); + return isExplicitTerminalSession(session) ? session : undefined; } listActiveManagedSessions( @@ -321,6 +323,12 @@ function workspaceSessionStatus(status: string): WorkspaceSessionStatus { return "unknown"; } +function isExplicitTerminalSession( + session: WorkspaceSession | undefined, +): session is WorkspaceSession & { status: TerminalWorkspaceSessionStatus } { + return session?.status === "released" || session?.status === "missing"; +} + function rowToWorkspaceConversationBinding( row: WorkspaceConversationBindingRow, ): WorkspaceConversationBinding { From 9aa3acfed3ca64adcb6560fa45a4cbb4722cd5b8 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:35:02 +0300 Subject: [PATCH 21/35] docs(workspaces): document explicit workspace release --- docs/configuration.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..8e0cb7f02 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,8 +83,14 @@ 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. 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 From 7b622b5c14ae0cac5630cfe266290d820103a77b Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:36:53 +0300 Subject: [PATCH 22/35] docs(workspaces): explain terminal lease release workflow --- docs/chatgpt-coding-workflow.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f6826617c..31fe7559f 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -89,6 +89,21 @@ 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` From 9e348b38a1d3bc09ac9eb4a7018ef5f44dbffaf9 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:37:49 +0300 Subject: [PATCH 23/35] fix(db): repair missing workspace schema before lifecycle migration --- src/db/migrations.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index e062c4e6b..08bc17f99 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -241,11 +241,12 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { } function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void { - // Legacy/interrupted test and recovery databases can claim migration 1 while - // lacking the workspace table entirely. Do not make unrelated stores fail to - // open in that state; a WorkspaceStore on such a database already has no - // usable workspace state and will fail closed independently. - if (!tableExists(sqlite, "workspace_sessions")) return; + // 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"); From 8a2dc5e6979b04648e468263e308e57b6a6ee6fc Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:39:01 +0300 Subject: [PATCH 24/35] test(workspaces): cover restart and unknown-state fail-closed behavior --- src/workspace-lifecycle.test.ts | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts index b80f45ac2..67c314bf9 100644 --- a/src/workspace-lifecycle.test.ts +++ b/src/workspace-lifecycle.test.ts @@ -4,6 +4,7 @@ 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 { releaseWorkspaceLease } from "./workspace-lifecycle.js"; @@ -89,6 +90,79 @@ test("explicit release persists across restart and retains the managed worktree" } }); +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); From f7c9d5e5f914ebd0b2340083461a60c85e93f685 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:39:57 +0300 Subject: [PATCH 25/35] fix(workspaces): continue bounded reconciliation through every page --- src/workspace-lifecycle.ts | 67 +++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 977cc3fc8..003dadc64 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -6,17 +6,32 @@ 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 { WorkspaceRegistry } from "./workspaces.js"; +import type { + ManagedWorkspaceReconciliationResult, + WorkspaceRegistry, +} from "./workspaces.js"; const RECONCILIATION_BATCH_SIZE = 128; const RECONCILIATION_RESCAN_MS = 6 * 60 * 60 * 1_000; interface ReconciliationState { - cursor?: string; running: boolean; lastFullSweepAt?: number; } +interface ManagedWorkspaceReconciler { + reconcileManagedWorktreeSessions(input: { + cursor?: string; + limit?: number; + }): Promise; +} + +export interface ManagedWorkspaceReconciliationSweepResult { + batches: number; + checked: number; + reconciled: number; +} + type TerminalWorkspaceSession = WorkspaceSession & { status: "released" | "missing"; }; @@ -117,6 +132,41 @@ export function registerWorkspaceLifecycleTool( ); } +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 requestManagedWorkspaceReconciliation( config: ServerConfig, workspaces: WorkspaceRegistry, @@ -128,7 +178,6 @@ export function requestManagedWorkspaceReconciliation( if (state.running) return; if ( - state.cursor === undefined && state.lastFullSweepAt !== undefined && Date.now() - state.lastFullSweepAt < RECONCILIATION_RESCAN_MS ) { @@ -136,20 +185,14 @@ export function requestManagedWorkspaceReconciliation( } state.running = true; - void workspaces - .reconcileManagedWorktreeSessions({ - cursor: state.cursor, - limit: RECONCILIATION_BATCH_SIZE, - }) + void runManagedWorkspaceReconciliationSweep(workspaces) .then((result) => { - state.cursor = result.nextCursor; - if (result.nextCursor === undefined) { - state.lastFullSweepAt = Date.now(); - } + 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, }); } From ca1d4f6bc3e4f153f70bd58c3232825a56d2c332 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:41:53 +0300 Subject: [PATCH 26/35] test(workspaces): prove bounded reconciliation completes every page --- src/workspace-lifecycle.test.ts | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts index 67c314bf9..514f86085 100644 --- a/src/workspace-lifecycle.test.ts +++ b/src/workspace-lifecycle.test.ts @@ -7,7 +7,10 @@ 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 { releaseWorkspaceLease } from "./workspace-lifecycle.js"; +import { + releaseWorkspaceLease, + runManagedWorkspaceReconciliationSweep, +} from "./workspace-lifecycle.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -214,6 +217,37 @@ test("managed session reconciliation is bounded and only terminalizes missing ro assert.equal((await stat(activeRoot)).isDirectory(), true); }); +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("release fails closed when a DevSpace process owns the workspace", async () => { let releaseCalls = 0; const workspaces = { From a729a9861da849b446374cc4d09ef42bdb31508b Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:44:56 +0300 Subject: [PATCH 27/35] fix(workspaces): require explicit terminal release result --- src/workspace-lifecycle.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 003dadc64..28f675bde 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -54,10 +54,12 @@ export function releaseWorkspaceLease( } const session = workspaces.releaseWorkspace(workspaceId); - if (session.status === "active") { - throw new Error(`Workspace ${workspaceId} could not be released safely.`); + if (session.status !== "released" && session.status !== "missing") { + throw new Error( + `Workspace ${workspaceId} did not reach an explicit terminal lifecycle state.`, + ); } - return session as TerminalWorkspaceSession; + return session; } export function registerWorkspaceLifecycleTool( From c2f10e8f56fae594ab4e287e7b44ff0f1b59591e Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:45:45 +0300 Subject: [PATCH 28/35] test(workspaces): reject nonterminal release results --- src/workspace-lifecycle.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts index 514f86085..460cf2ff4 100644 --- a/src/workspace-lifecycle.test.ts +++ b/src/workspace-lifecycle.test.ts @@ -267,6 +267,28 @@ test("release fails closed when a DevSpace process owns the workspace", async () 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" From 3b1ae8df266b593249a58f01e840b76c98dae8a7 Mon Sep 17 00:00:00 2001 From: Konstantsin Petrovskiy <47755041+wh1teee@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:47:32 +0300 Subject: [PATCH 29/35] fix(workspaces): narrow explicit terminal lifecycle state --- src/workspace-lifecycle.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 28f675bde..393267bbe 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -38,6 +38,12 @@ type TerminalWorkspaceSession = WorkspaceSession & { const reconciliationStates = new WeakMap(); +function isTerminalWorkspaceSession( + session: WorkspaceSession, +): session is TerminalWorkspaceSession { + return session.status === "released" || session.status === "missing"; +} + export function releaseWorkspaceLease( workspaces: Pick, processSessions: Pick, @@ -54,7 +60,7 @@ export function releaseWorkspaceLease( } const session = workspaces.releaseWorkspace(workspaceId); - if (session.status !== "released" && session.status !== "missing") { + if (!isTerminalWorkspaceSession(session)) { throw new Error( `Workspace ${workspaceId} did not reach an explicit terminal lifecycle state.`, ); From 124b4be6069ccbc2e26a2768c8f98e26de92d403 Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Wed, 2 Sep 2026 21:57:35 +0300 Subject: [PATCH 30/35] test(workspaces): close store before fixture cleanup --- src/workspace-lifecycle.test.ts | 92 +++++++++++++++++---------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts index 460cf2ff4..c0f22c1d3 100644 --- a/src/workspace-lifecycle.test.ts +++ b/src/workspace-lifecycle.test.ts @@ -169,52 +169,54 @@ test("legacy lifecycle state is neither reusable nor explicit release authority" test("managed session reconciliation is bounded and only terminalizes missing roots", async (t) => { const fixture = await lifecycleFixture(t); const store = new SqliteWorkspaceStore(fixture.stateDir); - t.after(() => store.close()); - - 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, - }); + 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); + 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 () => { From c853c4fdd0768222c82e0c022fa3ce6564cc15ca Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Thu, 3 Sep 2026 00:54:01 +0300 Subject: [PATCH 31/35] refactor(workspaces): expose reconciliation task state --- src/workspace-lifecycle.test.ts | 32 ++++++++++++++++++++++++++++++++ src/workspace-lifecycle.ts | 31 +++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/workspace-lifecycle.test.ts b/src/workspace-lifecycle.test.ts index c0f22c1d3..49010789f 100644 --- a/src/workspace-lifecycle.test.ts +++ b/src/workspace-lifecycle.test.ts @@ -8,7 +8,9 @@ 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"; @@ -250,6 +252,36 @@ test("managed reconciliation sweep continues through every bounded page", async }); }); +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 = { diff --git a/src/workspace-lifecycle.ts b/src/workspace-lifecycle.ts index 393267bbe..13d86a086 100644 --- a/src/workspace-lifecycle.ts +++ b/src/workspace-lifecycle.ts @@ -14,9 +14,12 @@ import type { const RECONCILIATION_BATCH_SIZE = 128; const RECONCILIATION_RESCAN_MS = 6 * 60 * 60 * 1_000; -interface ReconciliationState { +export interface ManagedWorkspaceReconciliationStatus { running: boolean; lastFullSweepAt?: number; + lastResult?: ManagedWorkspaceReconciliationSweepResult; + lastError?: string; + task?: Promise; } interface ManagedWorkspaceReconciler { @@ -36,7 +39,10 @@ type TerminalWorkspaceSession = WorkspaceSession & { status: "released" | "missing"; }; -const reconciliationStates = new WeakMap(); +const reconciliationStates = new WeakMap< + WorkspaceRegistry, + ManagedWorkspaceReconciliationStatus +>(); function isTerminalWorkspaceSession( session: WorkspaceSession, @@ -175,16 +181,23 @@ export async function runManagedWorkspaceReconciliationSweep( 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, -): void { +): Promise | undefined { const state = reconciliationStates.get(workspaces) ?? { running: false, }; reconciliationStates.set(workspaces, state); - if (state.running) return; + if (state.running) return state.task; if ( state.lastFullSweepAt !== undefined && Date.now() - state.lastFullSweepAt < RECONCILIATION_RESCAN_MS @@ -193,8 +206,10 @@ export function requestManagedWorkspaceReconciliation( } state.running = true; - void runManagedWorkspaceReconciliationSweep(workspaces) + 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", { @@ -206,11 +221,15 @@ export function requestManagedWorkspaceReconciliation( } }) .catch((error: unknown) => { + state.lastError = error instanceof Error ? error.message : String(error); logEvent(config.logging, "warn", "workspace_session_reconciliation_failed", { - error: error instanceof Error ? error.message : String(error), + error: state.lastError, }); }) .finally(() => { state.running = false; + state.task = undefined; }); + state.task = task; + return task; } From bafe2342f09b748e94959648f55fdc85558f3bb7 Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Thu, 3 Sep 2026 06:14:20 +0300 Subject: [PATCH 32/35] fix(workspaces): bind managed worktrees to conversations --- src/git-worktrees.ts | 19 ++- src/workspace-conversation.test.ts | 142 +++++++++++++++++++-- src/workspaces.ts | 192 ++++++++++++++++++++++++++--- 3 files changed, 326 insertions(+), 27 deletions(-) diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index 04986c9a5..9ff919942 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,10 @@ export async function createManagedWorktree(input: { }; } +export async function removeManagedWorktree(worktree: ManagedWorktree): 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/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 45c45a14d..841041d94 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,82 @@ 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("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 +582,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/workspaces.ts b/src/workspaces.ts index e40e8808f..2fe61b8f4 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -10,7 +10,11 @@ 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, @@ -98,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, @@ -114,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); @@ -197,6 +233,45 @@ 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); + this.store?.setConversationBinding({ + conversationScopeId, + targetKey, + workspaceSessionId: context.workspace.id, + }); + return { + ...context, + includeBootstrapContext: true, + }; + } + private async findReusableCheckoutWorkspace( binding: WorkspaceConversationBinding, ): Promise { @@ -226,6 +301,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); @@ -235,6 +375,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); @@ -420,12 +567,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: { @@ -445,6 +604,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, @@ -455,8 +617,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, From 10dfafd52bc0af43b1a1bbd202485d6eb3b39d4d Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Thu, 3 Sep 2026 06:21:31 +0300 Subject: [PATCH 33/35] docs(workspaces): document conversation worktree leases --- docs/chatgpt-coding-workflow.md | 42 ++++++++++++++++----------------- docs/configuration.md | 8 +++++++ 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 31fe7559f..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,10 +76,14 @@ 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 diff --git a/docs/configuration.md b/docs/configuration.md index 8e0cb7f02..07e32d7b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,14 @@ 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 operations. From f3b567bb1cf3c031407aa9d153f8ccfadf582d49 Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Thu, 3 Sep 2026 06:36:55 +0300 Subject: [PATCH 34/35] fix(workspaces): clean failed conversation leases --- src/workspace-conversation.test.ts | 28 +++++++++++++++++++ src/workspaces.ts | 44 ++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 841041d94..00f021ca9 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -261,6 +261,34 @@ test("failed managed worktree context initialization does not leave a lease or w 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"); diff --git a/src/workspaces.ts b/src/workspaces.ts index 2fe61b8f4..3c45b5fc7 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -261,17 +261,51 @@ export class WorkspaceRegistry { } const context = await this.openWorktreeWorkspace(input.path, input.baseRef); - this.store?.setConversationBinding({ - conversationScopeId, - targetKey, - workspaceSessionId: context.workspace.id, - }); + 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; + if (!worktree?.managed) throw error; + + try { + await removeManagedWorktree(worktree); + 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 { From 5733d7a5e90955cc1375ae8a59b40df36643e9f3 Mon Sep 17 00:00:00 2001 From: Petrovskiy Konstantsin Date: Thu, 3 Sep 2026 06:38:12 +0300 Subject: [PATCH 35/35] fix(workspaces): type failed lease cleanup --- src/git-worktrees.ts | 4 +++- src/workspaces.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index 9ff919942..976666c08 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -97,7 +97,9 @@ export async function createManagedWorktree(input: { }; } -export async function removeManagedWorktree(worktree: ManagedWorktree): Promise { +export async function removeManagedWorktree( + worktree: Pick, +): Promise { await git(["worktree", "remove", worktree.path], worktree.sourceRoot); } diff --git a/src/workspaces.ts b/src/workspaces.ts index 3c45b5fc7..37ee80a60 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -282,10 +282,11 @@ export class WorkspaceRegistry { ): Promise { this.workspaces.delete(workspace.id); const worktree = workspace.worktree; - if (!worktree?.managed) throw error; + const sourceRoot = workspace.sourceRoot; + if (!worktree?.managed || !sourceRoot) throw error; try { - await removeManagedWorktree(worktree); + await removeManagedWorktree({ sourceRoot, path: worktree.path }); const transitioned = this.store?.markSessionMissing( workspace.id, "managed_worktree_open_failed",