Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2da45af
feat(workspaces): persist terminal lifecycle state
wh1teee Sep 2, 2026
8d1325c
feat(workspaces): migrate lifecycle terminal metadata
wh1teee Sep 2, 2026
f9ce7ec
feat(workspaces): add atomic release lifecycle
wh1teee Sep 2, 2026
738a122
feat(workspaces): expose running process lease guard
wh1teee Sep 2, 2026
de857d7
feat(workspaces): release and reconcile managed leases
wh1teee Sep 2, 2026
279658b
feat(workspaces): add explicit close and bounded reconciliation
wh1teee Sep 2, 2026
a7def9d
feat(workspaces): expose close workspace tool
wh1teee Sep 2, 2026
c173df1
perf(workspaces): make reconciliation event-driven and bounded
wh1teee Sep 2, 2026
f2eddb0
refactor(workspaces): isolate lease release guard for testing
wh1teee Sep 2, 2026
708eaa5
test(workspaces): cover zero-loss lifecycle invariants
wh1teee Sep 2, 2026
91df46f
fix(workspaces): preserve terminal status narrowing
wh1teee Sep 2, 2026
e6744ef
feat(workspaces): name terminal lifecycle tool
wh1teee Sep 2, 2026
ea5bd3a
feat(workspaces): teach codex explicit lease release
wh1teee Sep 2, 2026
d1aa80c
fix(workspaces): keep codex surface unchanged apart from lifecycle gu…
wh1teee Sep 2, 2026
ce1ef64
feat(workspaces): teach claude explicit lease release
wh1teee Sep 2, 2026
8371e30
test(workspaces): include lifecycle tool in host surfaces
wh1teee Sep 2, 2026
55e2a23
fix(workspaces): fail closed on legacy lifecycle states
wh1teee Sep 2, 2026
40b5106
fix(db): tolerate interrupted legacy workspace schema
wh1teee Sep 2, 2026
14987ce
test(db): include workspace lifecycle migration
wh1teee Sep 2, 2026
f9af207
fix(workspaces): require explicit terminal authority from transitions
wh1teee Sep 2, 2026
9aa3acf
docs(workspaces): document explicit workspace release
wh1teee Sep 2, 2026
7b622b5
docs(workspaces): explain terminal lease release workflow
wh1teee Sep 2, 2026
9e348b3
fix(db): repair missing workspace schema before lifecycle migration
wh1teee Sep 2, 2026
8a2dc5e
test(workspaces): cover restart and unknown-state fail-closed behavior
wh1teee Sep 2, 2026
f7c9d5e
fix(workspaces): continue bounded reconciliation through every page
wh1teee Sep 2, 2026
ca1d4f6
test(workspaces): prove bounded reconciliation completes every page
wh1teee Sep 2, 2026
a729a98
fix(workspaces): require explicit terminal release result
wh1teee Sep 2, 2026
c2f10e8
test(workspaces): reject nonterminal release results
wh1teee Sep 2, 2026
3b1ae8d
fix(workspaces): narrow explicit terminal lifecycle state
wh1teee Sep 2, 2026
124b4be
test(workspaces): close store before fixture cleanup
wh1teee Sep 2, 2026
c853c4f
refactor(workspaces): expose reconciliation task state
wh1teee Sep 2, 2026
bafe234
fix(workspaces): bind managed worktrees to conversations
wh1teee Sep 3, 2026
10dfafd
docs(workspaces): document conversation worktree leases
wh1teee Sep 3, 2026
f3b567b
fix(workspaces): clean failed conversation leases
wh1teee Sep 3, 2026
5733d7a
fix(workspaces): type failed lease cleanup
wh1teee Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 38 additions & 21 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -80,15 +76,34 @@ Managed worktrees are created under:
Worktree mode requires a Git repository with at least one commit. It starts from
`HEAD` unless `baseRef` is provided.

Each worktree-mode call creates a new managed worktree and returns a new
`workspaceId`. Reuse that ID for work inside that worktree; call
`open_workspace` in worktree mode again only when another isolated worktree is
actually required.
With supported conversation metadata, the first worktree-mode open creates one
managed worktree lease for the conversation, canonical Git repository, and base
ref. Repeated or concurrent opens reuse that same `workspaceId`, including after
a DevSpace restart. A different conversation or base ref receives a separate
managed worktree. After `close_workspace` releases a terminal lease, the next
open creates a fresh worktree. Hosts without supported conversation metadata
continue to receive a fresh worktree for each open, so callers should still
reuse the returned `workspaceId` directly whenever possible.

Uncommitted source checkout changes are not copied into the managed worktree.
DevSpace reports when the source checkout was dirty so the model can decide how
to proceed with the user.

## Release A Terminal Workspace

Call `close_workspace` once only when work in that workspace is genuinely
terminal and no DevSpace process session is still running for it. Closing the
workspace releases its durable DevSpace lease and makes that `workspaceId`
non-reusable.

`close_workspace` does not delete a managed worktree, branch, commit, or project
file. Worktree removal remains a separate repository-policy operation that can
apply Git cleanliness, integration, process, lock, and other safety checks.

Do not infer terminal state from a response ending, MCP transport closure,
server restart, workspace age, or filesystem mtime. Paused or resumable work
must keep its lease active.

## Project Instructions

When a workspace opens, DevSpace loads root-level instruction files:
Expand Down Expand Up @@ -153,6 +168,7 @@ sessions for that workspace.
The Claude surface exposes these tool names:

- `open_workspace`
- `close_workspace`
- `read`
- `write`
- `edit`
Expand All @@ -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`
Expand Down
18 changes: 16 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,22 @@ rejected so spelling mistakes cannot silently alter behavior.

| Value | Tool surface |
| --- | --- |
| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. |
| `claude` | `open_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. |
| `codex` | Default. `open_workspace`, `close_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. |
| `claude` | `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. |

`close_workspace` is an explicit lease release, not a deletion primitive. Call
it only when work in that workspace is genuinely terminal. It does not remove a
managed worktree, branch, commit, or project files, and a released workspace ID
cannot be reused. A dropped MCP transport, server restart, age, or filesystem
mtime does not imply release.

When the host supplies supported conversation metadata, a managed worktree is
leased to that conversation by canonical Git repository and base ref. Repeated
or concurrent opens reuse the active lease across DevSpace restarts instead of
creating duplicate worktrees. Releasing the workspace removes that binding, so
the next open creates a fresh managed worktree. Hosts without conversation
metadata keep the explicit `workspaceId` workflow and do not infer reuse from
age or filesystem state.

The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses
its shell tool with programs such as `rg`, `find`, and `ls` when it needs those
Expand Down
29 changes: 29 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -235,6 +240,30 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void {
sqlite.exec("alter table local_agent_sessions rename column thinking to effort");
}

function migrateWorkspaceTerminalLifecycle(sqlite: Database.Database): void {
// Interrupted legacy upgrades can have migration 1 recorded while the
// workspace tables are absent. Repair the baseline instead of recording v7
// against a database that still cannot persist workspace lifecycle state.
if (!tableExists(sqlite, "workspace_sessions")) {
migrateWorkspaceState(sqlite);
}

addColumnIfMissing(sqlite, "workspace_sessions", "terminal_at", "text");
addColumnIfMissing(sqlite, "workspace_sessions", "terminal_reason", "text");
sqlite.exec(`
create index if not exists workspace_sessions_lifecycle_idx
on workspace_sessions(status, mode, managed, id);
`);
}

function tableExists(sqlite: Database.Database, table: string): boolean {
return Boolean(
sqlite
.prepare("select 1 from sqlite_master where type = 'table' and name = ? limit 1")
.get(table),
);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions",
Expand Down
8 changes: 8 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
],
);

Expand Down
21 changes: 17 additions & 4 deletions src/git-worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ManagedWorktree> {
}): Promise<string> {
const sourcePath = assertAllowedPath(input.sourcePath, input.config.allowedRoots);

try {
Expand All @@ -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<ManagedWorktree> {
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;
Expand Down Expand Up @@ -90,6 +97,12 @@ export async function createManagedWorktree(input: {
};
}

export async function removeManagedWorktree(
worktree: Pick<ManagedWorktree, "sourceRoot" | "path">,
): Promise<void> {
await git(["worktree", "remove", worktree.path], worktree.sourceRoot);
}

async function resolveGitRoot(path: string, allowedRoots: string[]): Promise<string> {
try {
const output = await git(["rev-parse", "--show-toplevel"], path);
Expand Down
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ 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();
Expand Down
7 changes: 7 additions & 0 deletions src/process-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
];

Expand Down
2 changes: 1 addition & 1 deletion src/tool-surfaces/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/tool-surfaces/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 18 additions & 3 deletions src/tool-surfaces/index.ts
Original file line number Diff line number Diff line change
@@ -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<ToolMode, ToolSurface> = {
claude: {
register: registerClaudeTools,
register: registerWithWorkspaceLifecycle(registerClaudeTools),
instructions: claudeInstructions,
},
codex: {
register: registerCodexTools,
register: registerWithWorkspaceLifecycle(registerCodexTools),
instructions: codexInstructions,
},
};
Expand Down
1 change: 1 addition & 0 deletions src/tool-surfaces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading