Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
11 changes: 11 additions & 0 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ 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 sends an anonymized conversation identifier in
`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope:
if `open_workspace` is called again for the same path, mode, and base ref in the
same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits
the project instructions, skills, subagent metadata, and diagnostics already
returned by the first call. The conversation binding is persisted so reconnecting
the MCP transport or restarting DevSpace does not create another managed worktree
for the same ChatGPT conversation and target. The workspace card still receives
the complete hidden display payload, so first and repeated calls render the same
workspace details without adding those fields to the model transcript again.

Do not reopen the same folder unless:

- the `workspaceId` is rejected as unknown
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
24 changes: 24 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const migrations: Migration[] = [
name: "local-agent-sessions",
up: migrateLocalAgentSessions,
},
{
version: 4,
name: "workspace-conversation-bindings",
up: migrateWorkspaceConversationBindings,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -174,6 +179,25 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void {
addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text");
}

function migrateWorkspaceConversationBindings(sqlite: Database.Database): void {
sqlite.exec(`
create table if not exists workspace_conversation_bindings (
conversation_scope_hash text not null,
target_key text not null,
workspace_session_id text not null,
created_at text not null,
last_used_at text not null,
primary key (conversation_scope_hash, target_key),
foreign key (workspace_session_id)
references workspace_sessions(id)
on delete cascade
);

create index if not exists workspace_conversation_bindings_workspace_idx
on workspace_conversation_bindings(workspace_session_id);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions",
Expand Down
19 changes: 19 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ export const loadedAgentFiles = sqliteTable(
],
);

export const workspaceConversationBindings = sqliteTable(
"workspace_conversation_bindings",
{
conversationScopeHash: text("conversation_scope_hash").notNull(),
targetKey: text("target_key").notNull(),
workspaceSessionId: text("workspace_session_id")
.notNull()
.references(() => workspaceSessions.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at").notNull(),
},
(table) => [
primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }),
index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId),
],
);

export const oauthClients = sqliteTable(
"oauth_clients",
{
Expand Down Expand Up @@ -101,5 +118,7 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect;
export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert;
export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect;
export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert;
export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect;
export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert;
export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect;
export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert;
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ version: 1, name: "workspace-state" },
{ version: 2, name: "oauth-state" },
{ version: 3, name: "local-agent-sessions" },
{ version: 4, name: "workspace-conversation-bindings" },
]);
} finally {
database.close();
Expand Down
26 changes: 26 additions & 0 deletions src/request-meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { openAiConversationScopeHash } from "./request-meta.js";

assert.equal(openAiConversationScopeHash(undefined), undefined);
assert.equal(openAiConversationScopeHash({}), undefined);
assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined);

const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" });
assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/);
assert.equal(
sessionOnly,
openAiConversationScopeHash({ "openai/session": "chat-1" }),
);
assert.notEqual(
sessionOnly,
openAiConversationScopeHash({ "openai/session": "chat-2" }),
);

assert.equal(
sessionOnly,
openAiConversationScopeHash({
"openai/session": "chat-1",
"openai/subject": "user-1",
"openai/organization": "org-1",
}),
);
20 changes: 20 additions & 0 deletions src/request-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";

function metadataString(
meta: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = meta?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}

export function openAiConversationScopeHash(
meta: Record<string, unknown> | undefined,
): string | undefined {
const session = metadataString(meta, "openai/session");
if (!session) return undefined;

return createHash("sha256")
.update(JSON.stringify(["openai", session]))
.digest("hex");
}
10 changes: 10 additions & 0 deletions src/review-checkpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ try {
assert.equal(firstReview.files.some((file) => file.path === "new.txt"), true);
assert.match(firstReview.patch, /world/);

const restartedManager = createReviewCheckpointManager();
await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root });
const afterRestart = await restartedManager.reviewChanges({
workspaceId: "ws_review",
root,
markReviewed: false,
});
assert.equal(afterRestart.summary.files, 2);
assert.match(afterRestart.patch, /world/);

const stillUnreviewed = await manager.reviewChanges({
workspaceId: "ws_review",
root,
Expand Down
78 changes: 64 additions & 14 deletions src/review-checkpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,32 @@ const REVIEW_REF_PREFIX = "refs/devspace/review";

export function createReviewCheckpointManager(): ReviewCheckpointManager {
const states = new Map<string, WorkspaceReviewState>();
const initializations = new Map<string, Promise<void>>();

return {
async initializeWorkspace({ workspaceId, root }) {
const refs = reviewRefs(workspaceId);
const state: WorkspaceReviewState = { root, ...refs };
states.set(workspaceId, state);
const existingState = states.get(workspaceId);
if (
existingState?.root === root &&
(existingState.gitRoot !== undefined || existingState.diagnostic !== undefined)
) {
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fix the readiness race between initializeWorkspaceState and its consumers.

initializeWorkspaceState publishes state into states synchronously (line 127) before eligibility resolves, then sets state.gitRoot = eligibility.gitRoot (line 136) before the open/baseline refs are verified or created (lines 137-149). This creates two related problems:

  1. initializeWorkspace's own early-return check at Lines 56-59 treats gitRoot !== undefined as "fully initialized." A second concurrent initializeWorkspace call for the same workspaceId can see gitRoot set and return immediately, bypassing the initializations pending-map wait, even though ref creation is still in flight.
  2. reviewChanges (unchanged in this diff) only checks if (!state) before deciding whether to call initializeWorkspace. Since state is set synchronously and early, a concurrent reviewChanges call sees a non-null but incomplete state, skips initializeWorkspace entirely, and then either throws a generic "requires a Git workspace" error (if gitRoot isn't set yet) or runs git rev-parse --verify <ref>^{commit} against a ref that doesn't exist yet (if gitRoot is set but refs aren't created).

This is the same issue raised in a previous review round (marked "Addressed"), but the current code still exhibits it. Set state.gitRoot only after refs are confirmed or created, and make reviewChanges re-enter initializeWorkspace whenever gitRoot/diagnostic are both absent, mirroring the readiness condition already used at Lines 56-59.

🔧 Proposed fix
     state.gitRoot = eligibility.gitRoot;
     const [hasOpenRef, hasBaselineRef] = await Promise.all([
       hasCommitRef(eligibility.gitRoot, state.openRef),
       hasCommitRef(eligibility.gitRoot, state.baselineRef),
     ]);
-    if (hasOpenRef && hasBaselineRef) return;
-
-    const commit = await createWorkingTreeSnapshot(eligibility.gitRoot);
-    if (!hasOpenRef) {
-      await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]);
-    }
-    if (!hasBaselineRef) {
-      await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]);
-    }
+    if (!hasOpenRef || !hasBaselineRef) {
+      const commit = await createWorkingTreeSnapshot(eligibility.gitRoot);
+      if (!hasOpenRef) {
+        await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]);
+      }
+      if (!hasBaselineRef) {
+        await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]);
+      }
+    }
+    state.gitRoot = eligibility.gitRoot;

Also update the unchanged reviewChanges readiness check outside this range:

async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) {
  let state = states.get(workspaceId);
  if (!state || (state.gitRoot === undefined && state.diagnostic === undefined)) {
    await this.initializeWorkspace({ workspaceId, root });
    state = states.get(workspaceId);
  }

  if (!state?.gitRoot) {
    throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version.");
  }
  // ...

Also applies to: 120-163

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/review-checkpoints.ts` around lines 56 - 59, Fix workspace readiness
across initializeWorkspaceState and reviewChanges: defer assigning state.gitRoot
until refs are verified or created, so gitRoot indicates complete initialization
and concurrent callers still await the pending initialization. Update
reviewChanges to call initializeWorkspace whenever state is missing or both
gitRoot and diagnostic are undefined, then refresh state before continuing.

return;
}

const pending = initializations.get(workspaceId);
if (pending) {
await pending;
return;
}

const initialize = initializeWorkspaceState(states, workspaceId, root);
initializations.set(workspaceId, initialize);
try {
const eligibility = await getGitEligibility(root);
if (!eligibility.ok || !eligibility.gitRoot) {
state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version.";
return;
await initialize;
} finally {
if (initializations.get(workspaceId) === initialize) {
initializations.delete(workspaceId);
}

state.gitRoot = eligibility.gitRoot;
const commit = await createWorkingTreeSnapshot(eligibility.gitRoot);
await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]);
await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]);
} catch (error) {
state.diagnostic = error instanceof Error ? error.message : String(error);
}
},

Expand Down Expand Up @@ -111,6 +117,50 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager {
};
}

async function initializeWorkspaceState(
states: Map<string, WorkspaceReviewState>,
workspaceId: string,
root: string,
): Promise<void> {
const refs = reviewRefs(workspaceId);
const state: WorkspaceReviewState = { root, ...refs };
states.set(workspaceId, state);

try {
const eligibility = await getGitEligibility(root);
if (!eligibility.ok || !eligibility.gitRoot) {
state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version.";
return;
}

state.gitRoot = eligibility.gitRoot;
const [hasOpenRef, hasBaselineRef] = await Promise.all([
hasCommitRef(eligibility.gitRoot, state.openRef),
hasCommitRef(eligibility.gitRoot, state.baselineRef),
]);
if (hasOpenRef && hasBaselineRef) return;

const commit = await createWorkingTreeSnapshot(eligibility.gitRoot);
if (!hasOpenRef) {
await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]);
}
if (!hasBaselineRef) {
await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]);
Comment on lines +141 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial checkpoint restore hides edits

When DevSpace restarts with the workspace open ref present but the baseline ref missing, initialization preserves the open ref but snapshots the current edited tree into the missing baseline. The next show_changes call compares against that fresh baseline and reports no changes, omitting edits made before the restart.

}
} catch (error) {
state.diagnostic = error instanceof Error ? error.message : String(error);
}
}

async function hasCommitRef(gitRoot: string, ref: string): Promise<boolean> {
try {
await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`]);
return true;
} catch {
return false;
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function reviewRefs(workspaceId: string): Pick<WorkspaceReviewState, "openRef" | "baselineRef"> {
const segment = safeWorkspaceRefSegment(workspaceId);
return {
Expand Down
Loading
Loading