diff --git a/src/core-mutation-tools.ts b/src/core-mutation-tools.ts index ea19fe62e..a84d2db42 100644 --- a/src/core-mutation-tools.ts +++ b/src/core-mutation-tools.ts @@ -364,6 +364,51 @@ export function registerCoreMutationSessionTools( }, ); + registerAppTool( + server, + "core_mutation_session_reconcile_synchronous", + { + title: "Reconcile Core synchronous Git effect", + description: + "Inspect the exact physical workspace after an unresolved synchronous Git effect and clear only the SYNCHRONOUS_GIT writer pin when scope and deletion checks remain valid. This never retries Git, clears other writer domains, or grants completion authority.", + inputSchema: { + workspaceId: z.string(), + sessionId: z.string(), + bindingHash: z.string(), + }, + outputSchema: z.object({ + session: outputSchema, + snapshot: z.record(z.string(), z.unknown()), + }), + _meta: {}, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, sessionId, bindingHash }, extra) => { + const workspace = workspaces.getWorkspace(workspaceId); + try { + const reconciled = await store.reconcileSynchronousEffect({ + sessionId, + workspaceSessionId: workspaceId, + workspaceRoot: workspace.root, + actorKey: actorKeyRequired(extra), + bindingHash, + }); + return { + content: [{ + type: "text" as const, + text: `Core synchronous Git effect reconciled for ${sessionId}; no Git effect was replayed.`, + }], + structuredContent: { + session: publicSession(reconciled.session), + snapshot: reconciled.snapshot as unknown as Record, + }, + }; + } catch (error) { + throw toolError(error); + } + }, + ); + registerAppTool( server, "core_mutation_session_snapshot", diff --git a/src/server.test.ts b/src/server.test.ts index 8cf39aa51..6ca5ba873 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1476,6 +1476,48 @@ test("Core-bound mutation session tools are registered for durable mutation admi const tools = await context.client.listTools(); assert.ok(tools.tools.some((tool) => tool.name === "core_mutation_session_open")); assert.ok(tools.tools.some((tool) => tool.name === "core_mutation_session_status")); + assert.ok(tools.tools.some((tool) => tool.name === "core_mutation_session_reconcile_synchronous")); +}); + +test("Core synchronous Git reconciliation clears the exact writer pin without replaying Git", async (t) => { + const conversationScopeId = "core-sync-git-reconcile"; + const conversation = { "openai/session": conversationScopeId }; + const context = await fixture(t, { git: true, coreMutation: true }); + const opened = await callOpen(context.client, context.project, conversationScopeId); + const workspaceId = structuredContent(opened).workspaceId as string; + const bound = await bindTestCoreSession({ + fixture: context, + workspaceId, + workspaceRoot: context.project, + conversationScopeId, + allowedPaths: ["AGENTS.md"], + }); + const actorKey = `openai:${createHash("sha256").update(conversationScopeId).digest("hex")}`; + await context.coreMutationSessions!.admitEffect({ + workspaceSessionId: workspaceId, + workspaceRoot: context.project, + workspaceMode: "checkout", + managed: false, + actorKey, + pointer: { required: true, sessionId: bound.session.id, bindingHash: bound.session.bindingHash }, + paths: ["AGENTS.md"], + pathContainment: "NOT_PROVEN", + synchronousPostEffectCheck: true, + }); + const beforeHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: context.project })).stdout.trim(); + assert.deepEqual(context.coreMutationSessions!.getById(bound.session.id)?.writerDomains, ["SYNCHRONOUS_GIT"]); + assert.equal(context.coreMutationSessions!.getById(bound.session.id)?.writerReconciliationState, "OUTCOME_UNKNOWN"); + + const reconciled = await context.client.callTool({ + name: "core_mutation_session_reconcile_synchronous", + arguments: { workspaceId, sessionId: bound.session.id, bindingHash: bound.session.bindingHash }, + _meta: conversation, + }); + assert.equal(reconciled.isError, undefined, responseText(reconciled)); + const session = structuredContent(reconciled).session as Record; + assert.deepEqual(session.writerDomains, []); + assert.equal(session.writerReconciliationState, "CLEAR"); + assert.equal((await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: context.project })).stdout.trim(), beforeHead); }); test("createMcpServer without Core store fails closed unless explicit test bypass is supplied", async (t) => {