feat(FN-5351): add merge audit telemetry with typed events and integration
FN-5351 adds structured telemetry for merge audit events and integration worktree state, including typed ref-advance tracking, terminal handoff fallback audit, and integration state probes with corresponding reliability backstop tests and documentation updates. Fusion-Task-Id: FN-5351
This commit is contained in:
committed by
gsxdsm
parent
caeb6a7e00
commit
5a76a89071
@@ -12,6 +12,7 @@ import * as branchAutocorrect from "../branch-autocorrect.js";
|
||||
import {
|
||||
acquireReuseHandoff,
|
||||
MergeHandoffRefusedError,
|
||||
probeIntegrationWorktreeState,
|
||||
releaseReuseHandoff,
|
||||
resolveIntegrationRemote,
|
||||
resolveMergeIntegrationRoot,
|
||||
@@ -124,6 +125,103 @@ describe("resolveIntegrationRemote", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeIntegrationWorktreeState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null state when integration branch is not checked out in any linked worktree", async () => {
|
||||
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-1", "/tmp/task"]]));
|
||||
|
||||
await expect(
|
||||
probeIntegrationWorktreeState({
|
||||
rootDir: "/tmp/project-root",
|
||||
integrationBranch: "main",
|
||||
projectRoot: "/tmp/project-root",
|
||||
}),
|
||||
).resolves.toEqual({ userCheckout: null, dirtyFingerprint: null });
|
||||
});
|
||||
|
||||
it("returns clean user checkout state", async () => {
|
||||
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["main", "/tmp/project-root"]]));
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const command = String(cmd);
|
||||
if (command === "git diff -z --name-only") return Buffer.from("");
|
||||
if (command === "git diff -z --cached --name-only") return Buffer.from("");
|
||||
if (command === "git status -z --porcelain") return Buffer.from("");
|
||||
if (command === "git diff HEAD") return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const state = await probeIntegrationWorktreeState({
|
||||
rootDir: "/tmp/project-root",
|
||||
integrationBranch: "main",
|
||||
projectRoot: "/tmp/project-root",
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
userCheckout: {
|
||||
worktreePath: "/tmp/project-root",
|
||||
dirty: false,
|
||||
untrackedCount: 0,
|
||||
dirtyPathSample: [],
|
||||
},
|
||||
dirtyFingerprint: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns dirty user checkout state with staged, unstaged, and untracked files", async () => {
|
||||
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["main", "/tmp/project-root"]]));
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const command = String(cmd);
|
||||
if (command === "git diff -z --name-only") return Buffer.from("unstaged.ts\0");
|
||||
if (command === "git diff -z --cached --name-only") return Buffer.from("staged.ts\0");
|
||||
if (command === "git status -z --porcelain") return Buffer.from("M modified.ts\0?? untracked.txt\0");
|
||||
if (command === "git diff HEAD") return Buffer.from("diff --git a/a b/a\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const state = await probeIntegrationWorktreeState({
|
||||
rootDir: "/tmp/project-root",
|
||||
integrationBranch: "main",
|
||||
projectRoot: "/tmp/project-root",
|
||||
});
|
||||
|
||||
expect(state.userCheckout).toMatchObject({
|
||||
worktreePath: "/tmp/project-root",
|
||||
dirty: true,
|
||||
untrackedCount: 1,
|
||||
});
|
||||
expect(state.userCheckout?.dirtyPathSample).toEqual([
|
||||
"staged.ts",
|
||||
"unstaged.ts",
|
||||
"untracked.txt",
|
||||
]);
|
||||
expect(state.dirtyFingerprint).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("supports master as integration branch", async () => {
|
||||
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["master", "/tmp/project-root"]]));
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const command = String(cmd);
|
||||
if (command === "git diff -z --name-only") return Buffer.from("");
|
||||
if (command === "git diff -z --cached --name-only") return Buffer.from("");
|
||||
if (command === "git status -z --porcelain") return Buffer.from("");
|
||||
if (command === "git diff HEAD") return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const state = await probeIntegrationWorktreeState({
|
||||
rootDir: "/tmp/project-root",
|
||||
integrationBranch: "master",
|
||||
projectRoot: "/tmp/project-root",
|
||||
});
|
||||
|
||||
expect(state.userCheckout?.worktreePath).toBe("/tmp/project-root");
|
||||
expect(state.userCheckout?.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acquireReuseHandoff", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -227,6 +227,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
@@ -434,6 +435,29 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
|
||||
expect(probe.fetchCalled).toBe(true);
|
||||
expect(probe.ffCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("emits integration-worktree-state once per merge attempt", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
|
||||
);
|
||||
setupSyncMock({ behind: 0, ahead: 0 });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const events = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls
|
||||
.map(([event]) => event)
|
||||
.filter((event: any) => event?.mutationType === "merge:integration-worktree-state");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
domain: "git",
|
||||
mutationType: "merge:integration-worktree-state",
|
||||
metadata: expect.objectContaining({
|
||||
integrationBranch: "main",
|
||||
integrationMode: "cwd-integration",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ describe("advanceIntegrationBranchRef", () => {
|
||||
|
||||
expect(result).toEqual({ advanced: true, previousSha: expectedCurrentSha, newSha });
|
||||
expect(git(dir, `git rev-parse refs/heads/${integrationBranch}`)).toBe(newSha);
|
||||
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advanced");
|
||||
expect(events[0]?.metadata?.via).toBe("update-ref");
|
||||
expect(events[0]?.type).toBe("merge:integration-ref-advance");
|
||||
expect(events[0]?.metadata?.advanceMode).toBe("update-ref");
|
||||
expect(events[0]?.metadata?.succeeded).toBe(true);
|
||||
expect(events[0]?.metadata?.refName).toBe(`refs/heads/${integrationBranch}`);
|
||||
expect(events[0]?.target).toBe(integrationBranch);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -89,7 +91,8 @@ describe("advanceIntegrationBranchRef", () => {
|
||||
expect(result.reason).toBe("concurrent-advance");
|
||||
expect(result.observedCurrentSha).toBe(observedCurrentSha);
|
||||
expect(git(dir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
|
||||
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advance-failed");
|
||||
expect(events[0]?.type).toBe("merge:integration-ref-advance");
|
||||
expect(events[0]?.metadata?.succeeded).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ describe.skipIf(!hasGit)("reliability interaction: dirty integration worktree wi
|
||||
return ["checkout", "merge", "rebase", "update-ref"].includes(args[0] ?? "");
|
||||
})).toBe(false);
|
||||
|
||||
const advanceEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advanced");
|
||||
expect(advanceEvent?.metadata?.via).toBe("update-ref");
|
||||
const advanceEvent = events.find((event) => event.type === "merge:integration-ref-advance");
|
||||
expect(advanceEvent?.metadata?.advanceMode).toBe("update-ref");
|
||||
expect(advanceEvent?.metadata?.succeeded).toBe(true);
|
||||
expect(advanceEvent?.target).toBe(integrationBranch);
|
||||
if (integrationBranch === "master") {
|
||||
expect(JSON.stringify(advanceEvent)).not.toContain('"main"');
|
||||
@@ -114,10 +115,9 @@ describe.skipIf(!hasGit)("reliability interaction: dirty integration worktree wi
|
||||
if (result.advanced) throw new Error("expected refusal");
|
||||
expect(result.reason).toBe("concurrent-advance");
|
||||
expect(git(projectRootDir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
|
||||
const failureEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advance-failed");
|
||||
expect(failureEvent?.metadata?.reason).toBe("concurrent-advance");
|
||||
expect(failureEvent?.metadata?.expectedCurrentSha).toBe(expectedCurrentSha);
|
||||
expect(failureEvent?.metadata?.observedCurrentSha).toBe(observedCurrentSha);
|
||||
const failureEvent = events.find((event) => event.type === "merge:integration-ref-advance");
|
||||
expect(failureEvent?.metadata?.succeeded).toBe(false);
|
||||
expect(String(failureEvent?.metadata?.error ?? "")).toContain("concurrent-advance");
|
||||
} finally {
|
||||
rmSync(projectRootDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../pi.js", () => ({
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
prompt: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<unknown> }, prompt: string) => {
|
||||
await session.prompt(prompt);
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
async function setupReuseTask(taskId: string, baseBranch: "main" | "master") {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId,
|
||||
settings: { baseBranch, mergeIntegrationWorktree: "reuse-task-worktree", worktreeRebaseRemote: "origin" } as any,
|
||||
});
|
||||
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
|
||||
if (baseBranch === "master") {
|
||||
git(rootDir, "git branch -m main master");
|
||||
}
|
||||
|
||||
await store.updateTask(task.id, {
|
||||
baseBranch,
|
||||
branch,
|
||||
steps: (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
|
||||
currentStep: (actualTask?.steps ?? []).length,
|
||||
} as any);
|
||||
|
||||
await fixture.createBranch(branch);
|
||||
await fixture.writeAndCommit(`packages/engine/src/${taskId.toLowerCase()}.ts`, "export const v = 1;\n", "feat: merge content");
|
||||
await fixture.checkout(baseBranch);
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
|
||||
store.enqueueMergeQueue(task.id);
|
||||
|
||||
return { fixture, worktreePath, branch };
|
||||
}
|
||||
|
||||
describe("reliability interaction: integration-worktree-state telemetry", () => {
|
||||
it.skipIf(!hasGit)("captures dirty user checkout while successful reuse merge leaves user files untouched", async () => {
|
||||
const { fixture } = await setupReuseTask("FN-5351-RI-STATE-1", "main");
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
writeFileSync(join(rootDir, "README.md"), "# fixture\nuser edit\n");
|
||||
writeFileSync(join(rootDir, "UNTRACKED.txt"), "u\n");
|
||||
git(rootDir, "git add README.md");
|
||||
|
||||
const trackedBefore = readFileSync(join(rootDir, "README.md"), "utf-8");
|
||||
const untrackedBefore = readFileSync(join(rootDir, "UNTRACKED.txt"), "utf-8");
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||
const state = audits.find((event) => event.mutationType === "merge:integration-worktree-state");
|
||||
expect(state?.metadata).toMatchObject({
|
||||
integrationMode: "reuse-task-worktree",
|
||||
integrationBranch: "main",
|
||||
userCheckout: expect.objectContaining({ dirty: true }),
|
||||
});
|
||||
const advance = audits.find((event) => event.mutationType === "merge:integration-ref-advance");
|
||||
expect(advance?.metadata).toMatchObject({ refName: "refs/heads/main", succeeded: true });
|
||||
|
||||
expect(readFileSync(join(rootDir, "README.md"), "utf-8")).toBe(trackedBefore);
|
||||
expect(readFileSync(join(rootDir, "UNTRACKED.txt"), "utf-8")).toBe(untrackedBefore);
|
||||
expect(existsSync(join(rootDir, "UNTRACKED.txt"))).toBe(true);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("emits fallback-refused and no ref-advance when reused task worktree is dirty", async () => {
|
||||
const { fixture, worktreePath } = await setupReuseTask("FN-5351-RI-STATE-2", "main");
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "working-tree-dirty",
|
||||
});
|
||||
const latestTask = await store.getTask(task.id);
|
||||
expect(latestTask?.column).toBe("in-review");
|
||||
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||
const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ gate: "working-tree-dirty" });
|
||||
const fallbackRefused = audits.find((event) => event.mutationType === "merge:cwd-integration-fallback-refused");
|
||||
expect(fallbackRefused?.metadata).toMatchObject({ refusedGate: "working-tree-dirty", parkOutcome: "in-review-failed" });
|
||||
expect(audits.some((event) => event.mutationType === "merge:integration-ref-advance")).toBe(false);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("uses resolved master branch names in all new telemetry payloads", async () => {
|
||||
const { fixture } = await setupReuseTask("FN-5351-RI-STATE-3", "master");
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id }).filter((event) =>
|
||||
["merge:integration-worktree-state", "merge:cwd-integration-fallback-refused", "merge:integration-ref-advance"].includes(event.mutationType),
|
||||
);
|
||||
const state = audits.find((event) => event.mutationType === "merge:integration-worktree-state");
|
||||
const advance = audits.find((event) => event.mutationType === "merge:integration-ref-advance");
|
||||
expect(state?.metadata).toMatchObject({ integrationBranch: "master" });
|
||||
expect(advance?.metadata).toMatchObject({ integrationBranch: "master", refName: "refs/heads/master" });
|
||||
|
||||
for (const event of audits) {
|
||||
const payload = JSON.stringify(event.metadata ?? {});
|
||||
expect(payload).not.toContain("\"main\"");
|
||||
}
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -74,11 +74,11 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
|
||||
// Step 5c (FN-5279 reuse mode) advances the project root's integration
|
||||
// branch to the new squash commit so changes actually land on master.
|
||||
expect(auditTypes).toContain("merge:reuse-integration-branch-advanced");
|
||||
expect(auditTypes).toContain("merge:integration-ref-advance");
|
||||
const advanced = audits.find(
|
||||
(event) => event.mutationType === "merge:reuse-integration-branch-advanced",
|
||||
(event) => event.mutationType === "merge:integration-ref-advance",
|
||||
);
|
||||
expect(advanced?.metadata).toMatchObject({ via: "update-ref" });
|
||||
expect(advanced?.metadata).toMatchObject({ advanceMode: "update-ref", succeeded: true });
|
||||
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
|
||||
const rootTrackedStatusAfter = git(rootDir, "git status --porcelain --untracked-files=no");
|
||||
expect(rootTrackedStatusAfter).not.toBe(rootTrackedStatusBefore);
|
||||
|
||||
@@ -29,4 +29,62 @@ describe("run-audit provisioning mutation types", () => {
|
||||
|
||||
expect(store.events.map((event) => event.mutationType)).toEqual(types);
|
||||
});
|
||||
|
||||
it("accepts integration-worktree merge git mutation types", async () => {
|
||||
const store = new AuditStoreStub();
|
||||
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-1" });
|
||||
|
||||
await auditor.git({
|
||||
type: "merge:integration-worktree-state",
|
||||
target: "main",
|
||||
metadata: {
|
||||
taskId: "FN-1",
|
||||
integrationBranch: "main",
|
||||
integrationMode: "reuse-task-worktree",
|
||||
integrationRootDir: "/repo",
|
||||
taskWorktreePath: "/repo/.worktrees/fn-1",
|
||||
userCheckout: {
|
||||
worktreePath: "/repo",
|
||||
dirty: true,
|
||||
untrackedCount: 1,
|
||||
dirtyPathSample: ["README.md"],
|
||||
},
|
||||
dirtyFingerprint: "abc123",
|
||||
},
|
||||
});
|
||||
await auditor.git({
|
||||
type: "merge:cwd-integration-fallback-refused",
|
||||
target: "main",
|
||||
metadata: {
|
||||
taskId: "FN-1",
|
||||
integrationBranch: "main",
|
||||
refusedGate: "working-tree-dirty",
|
||||
refusedReason: "worktree has local changes",
|
||||
requestedMode: "reuse-task-worktree",
|
||||
taskWorktreePath: "/repo/.worktrees/fn-1",
|
||||
parkOutcome: "in-review-failed",
|
||||
},
|
||||
});
|
||||
await auditor.git({
|
||||
type: "merge:integration-ref-advance",
|
||||
target: "main",
|
||||
metadata: {
|
||||
taskId: "FN-1",
|
||||
integrationBranch: "main",
|
||||
refName: "refs/heads/main",
|
||||
fromSha: "1111111",
|
||||
toSha: "2222222",
|
||||
advanceMode: "fast-forward",
|
||||
succeeded: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.events).toHaveLength(3);
|
||||
expect(store.events.map((event) => event.domain)).toEqual(["git", "git", "git"]);
|
||||
expect(store.events.map((event) => event.mutationType)).toEqual([
|
||||
"merge:integration-worktree-state",
|
||||
"merge:cwd-integration-fallback-refused",
|
||||
"merge:integration-ref-advance",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ export interface ReuseHandoffInput {
|
||||
auditEmit?: (event: { type: string; target?: string; metadata?: Record<string, unknown> }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
async function snapshotDirtyFilesLocal(rootDir: string): Promise<Set<string>> {
|
||||
export async function snapshotDirtyFilesLocal(rootDir: string): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
try {
|
||||
const [unstagedOut, stagedOut, porcelainOut] = await Promise.all([
|
||||
@@ -188,7 +188,7 @@ async function snapshotDirtyFilesLocal(rootDir: string): Promise<Set<string>> {
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function gitDirtyFingerprintLocal(rootDir: string): Promise<string> {
|
||||
export async function gitDirtyFingerprintLocal(rootDir: string): Promise<string> {
|
||||
try {
|
||||
const [diffOut, statusOut] = await Promise.all([
|
||||
execFileAsync("git", ["diff", "HEAD"], {
|
||||
@@ -208,6 +208,65 @@ async function gitDirtyFingerprintLocal(rootDir: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntegrationWorktreeProbeResult {
|
||||
userCheckout: {
|
||||
worktreePath: string;
|
||||
dirty: boolean;
|
||||
untrackedCount: number;
|
||||
dirtyPathSample: string[];
|
||||
} | null;
|
||||
dirtyFingerprint: string | null;
|
||||
}
|
||||
|
||||
export interface ProbeIntegrationWorktreeStateInput {
|
||||
rootDir: string;
|
||||
integrationBranch: string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export async function probeIntegrationWorktreeState(
|
||||
input: ProbeIntegrationWorktreeStateInput,
|
||||
): Promise<IntegrationWorktreeProbeResult> {
|
||||
try {
|
||||
const branchMap = await getRegisteredWorktreeBranchMap(input.projectRoot);
|
||||
const caseInsensitiveMatches = Array.from(branchMap.entries())
|
||||
.filter(([branch]) => branch.toLowerCase() === input.integrationBranch.toLowerCase())
|
||||
.map(([, worktreePath]) => worktreePath);
|
||||
const registeredPath = branchMap.get(input.integrationBranch)
|
||||
?? caseInsensitiveMatches.find((worktreePath) => canonicalizePath(worktreePath) === canonicalizePath(input.rootDir))
|
||||
?? caseInsensitiveMatches[0]
|
||||
?? null;
|
||||
if (!registeredPath) {
|
||||
return { userCheckout: null, dirtyFingerprint: null };
|
||||
}
|
||||
|
||||
const dirtyPaths = Array.from(await snapshotDirtyFilesLocal(registeredPath)).sort();
|
||||
const dirtyFingerprint = await gitDirtyFingerprintLocal(registeredPath);
|
||||
let untrackedCount = 0;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["status", "-z", "--porcelain"], {
|
||||
cwd: registeredPath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
untrackedCount = stdout.split("\0").filter((entry) => entry.startsWith("?? ")).length;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
userCheckout: {
|
||||
worktreePath: registeredPath,
|
||||
dirty: dirtyPaths.length > 0 || Boolean(dirtyFingerprint),
|
||||
untrackedCount,
|
||||
dirtyPathSample: dirtyPaths.slice(0, 20),
|
||||
},
|
||||
dirtyFingerprint: dirtyFingerprint || null,
|
||||
};
|
||||
} catch {
|
||||
return { userCheckout: null, dirtyFingerprint: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function findOtherWorktreeUser(store: TaskStore, worktreePath: string, excludeTaskId: string): Promise<string | null> {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false } as never);
|
||||
for (const task of tasks) {
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
> {
|
||||
const {
|
||||
rootDir,
|
||||
projectRootDir,
|
||||
integrationBranch,
|
||||
newSha,
|
||||
expectedCurrentSha,
|
||||
@@ -81,23 +80,39 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
}
|
||||
|
||||
const ref = `refs/heads/${integrationBranch}`;
|
||||
const emitRefAdvance = async (input: {
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
fromSha: string | null;
|
||||
toSha: string;
|
||||
}): Promise<void> => {
|
||||
await audit.git({
|
||||
type: "merge:integration-ref-advance",
|
||||
target: integrationBranch,
|
||||
metadata: {
|
||||
taskId,
|
||||
integrationBranch,
|
||||
refName: ref,
|
||||
fromSha: input.fromSha,
|
||||
toSha: input.toSha,
|
||||
advanceMode: "update-ref",
|
||||
succeeded: input.succeeded,
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let observedCurrentSha = "";
|
||||
try {
|
||||
const { stdout } = await testHooks.runGit(["rev-parse", "--verify", ref], rootDir);
|
||||
observedCurrentSha = stdout.trim();
|
||||
} catch (error: unknown) {
|
||||
const diagnostic = error instanceof Error ? error.message : String(error);
|
||||
await audit.git({
|
||||
type: "merge:reuse-integration-branch-advance-failed",
|
||||
target: integrationBranch,
|
||||
metadata: {
|
||||
taskId,
|
||||
newSha,
|
||||
expectedCurrentSha,
|
||||
reason: "missing-current-sha",
|
||||
diagnostic,
|
||||
projectRootDir,
|
||||
},
|
||||
await emitRefAdvance({
|
||||
succeeded: false,
|
||||
fromSha: expectedCurrentSha || null,
|
||||
toSha: newSha,
|
||||
error: `missing-current-sha: ${diagnostic}`,
|
||||
});
|
||||
return {
|
||||
advanced: false,
|
||||
@@ -108,17 +123,11 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
|
||||
if (!observedCurrentSha) {
|
||||
const diagnostic = `Missing current sha for ${ref}`;
|
||||
await audit.git({
|
||||
type: "merge:reuse-integration-branch-advance-failed",
|
||||
target: integrationBranch,
|
||||
metadata: {
|
||||
taskId,
|
||||
newSha,
|
||||
expectedCurrentSha,
|
||||
reason: "missing-current-sha",
|
||||
diagnostic,
|
||||
projectRootDir,
|
||||
},
|
||||
await emitRefAdvance({
|
||||
succeeded: false,
|
||||
fromSha: expectedCurrentSha || null,
|
||||
toSha: newSha,
|
||||
error: `missing-current-sha: ${diagnostic}`,
|
||||
});
|
||||
return {
|
||||
advanced: false,
|
||||
@@ -129,18 +138,11 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
|
||||
if (observedCurrentSha !== expectedCurrentSha) {
|
||||
const diagnostic = `Expected ${expectedCurrentSha} but observed ${observedCurrentSha} for ${ref}`;
|
||||
await audit.git({
|
||||
type: "merge:reuse-integration-branch-advance-failed",
|
||||
target: integrationBranch,
|
||||
metadata: {
|
||||
taskId,
|
||||
newSha,
|
||||
expectedCurrentSha,
|
||||
observedCurrentSha,
|
||||
reason: "concurrent-advance",
|
||||
diagnostic,
|
||||
projectRootDir,
|
||||
},
|
||||
await emitRefAdvance({
|
||||
succeeded: false,
|
||||
fromSha: expectedCurrentSha,
|
||||
toSha: newSha,
|
||||
error: `concurrent-advance: ${diagnostic}`,
|
||||
});
|
||||
return {
|
||||
advanced: false,
|
||||
@@ -152,10 +154,10 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
|
||||
try {
|
||||
await testHooks.runGit(["update-ref", ref, newSha, expectedCurrentSha], rootDir);
|
||||
await audit.git({
|
||||
type: "merge:reuse-integration-branch-advanced",
|
||||
target: integrationBranch,
|
||||
metadata: { taskId, sha: newSha, via: "update-ref", expectedCurrentSha, projectRootDir },
|
||||
await emitRefAdvance({
|
||||
succeeded: true,
|
||||
fromSha: expectedCurrentSha,
|
||||
toSha: newSha,
|
||||
});
|
||||
return { advanced: true, previousSha: expectedCurrentSha, newSha };
|
||||
} catch (error: unknown) {
|
||||
@@ -163,18 +165,11 @@ export async function advanceIntegrationBranchRef(args: {
|
||||
const lower = diagnostic.toLowerCase();
|
||||
const isConcurrent = lower.includes("cannot lock ref") || lower.includes("is at") || lower.includes("expected");
|
||||
const reason = isConcurrent ? "concurrent-advance" : "ref-update-refused";
|
||||
await audit.git({
|
||||
type: "merge:reuse-integration-branch-advance-failed",
|
||||
target: integrationBranch,
|
||||
metadata: {
|
||||
taskId,
|
||||
newSha,
|
||||
expectedCurrentSha,
|
||||
observedCurrentSha,
|
||||
reason,
|
||||
diagnostic,
|
||||
projectRootDir,
|
||||
},
|
||||
await emitRefAdvance({
|
||||
succeeded: false,
|
||||
fromSha: observedCurrentSha || expectedCurrentSha,
|
||||
toSha: newSha,
|
||||
error: `${reason}: ${diagnostic}`,
|
||||
});
|
||||
return {
|
||||
advanced: false,
|
||||
|
||||
@@ -102,6 +102,7 @@ import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger
|
||||
import {
|
||||
acquireReuseHandoff,
|
||||
MergeHandoffRefusedError,
|
||||
probeIntegrationWorktreeState,
|
||||
releaseReuseHandoff,
|
||||
resolveIntegrationRemote,
|
||||
resolveMergeIntegrationRoot,
|
||||
@@ -6671,6 +6672,7 @@ export async function aiMergeTask(
|
||||
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||
projectDefaultBranch: resolvedIntegrationBranch,
|
||||
});
|
||||
const integrationBranch = resolvedIntegrationBranch;
|
||||
let branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
|
||||
const mergeRunId = generateSyntheticRunId("merge", taskId);
|
||||
@@ -7004,6 +7006,31 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const integrationWorktreeState = await probeIntegrationWorktreeState({
|
||||
rootDir: integrationRoot.rootDir,
|
||||
integrationBranch,
|
||||
projectRoot: projectRootDir,
|
||||
});
|
||||
await audit.git({
|
||||
type: "merge:integration-worktree-state",
|
||||
target: projectRootDir,
|
||||
metadata: {
|
||||
taskId,
|
||||
integrationBranch,
|
||||
integrationMode: integrationRoot.mode === "reuse-task-worktree" ? "reuse-task-worktree" : "cwd-integration",
|
||||
integrationRootDir: integrationRoot.rootDir,
|
||||
taskWorktreePath: task.worktree?.trim() || null,
|
||||
userCheckout: integrationWorktreeState.userCheckout,
|
||||
dirtyFingerprint: integrationWorktreeState.dirtyFingerprint,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: failed to emit merge:integration-worktree-state: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||
try {
|
||||
reuseHandoff = await acquireReuseHandoff({
|
||||
@@ -7061,6 +7088,25 @@ export async function aiMergeTask(
|
||||
classification,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await audit.git({
|
||||
type: "merge:cwd-integration-fallback-refused",
|
||||
target: integrationRoot.rootDir,
|
||||
metadata: {
|
||||
taskId,
|
||||
integrationBranch,
|
||||
refusedGate: error.gate,
|
||||
refusedReason: error.reason,
|
||||
requestedMode: requestedIntegrationMode === "reuse-task-worktree" ? "reuse-task-worktree" : "cwd-integration",
|
||||
taskWorktreePath: task.worktree?.trim() || null,
|
||||
parkOutcome: "in-review-failed",
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: failed to emit merge:cwd-integration-fallback-refused: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +166,58 @@ export type GitMutationType =
|
||||
| "merge:reuse-fallback-reused-existing-registration"
|
||||
| "merge:reuse-worktree-fresh-acquire"
|
||||
| "merge:reuse-worktree-fresh-acquired"
|
||||
| "merge:reuse-integration-branch-advanced"
|
||||
| "merge:reuse-integration-branch-advance-failed"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId: string;
|
||||
* integrationBranch: string;
|
||||
* integrationMode: "reuse-task-worktree" | "cwd-integration";
|
||||
* integrationRootDir: string;
|
||||
* taskWorktreePath: string | null;
|
||||
* userCheckout: {
|
||||
* worktreePath: string;
|
||||
* dirty: boolean;
|
||||
* untrackedCount: number;
|
||||
* dirtyPathSample: string[];
|
||||
* } | null;
|
||||
* dirtyFingerprint: string | null;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "merge:integration-worktree-state"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId: string;
|
||||
* integrationBranch: string;
|
||||
* refusedGate: string;
|
||||
* refusedReason: string;
|
||||
* requestedMode: "reuse-task-worktree" | "cwd-integration";
|
||||
* taskWorktreePath: string | null;
|
||||
* parkOutcome: "in-review-failed";
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "merge:cwd-integration-fallback-refused"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId: string;
|
||||
* integrationBranch: string;
|
||||
* refName: string;
|
||||
* fromSha: string | null;
|
||||
* toSha: string;
|
||||
* advanceMode: "fast-forward" | "non-fast-forward" | "update-ref";
|
||||
* aiResolved?: boolean;
|
||||
* succeeded: boolean;
|
||||
* error?: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "merge:integration-ref-advance"
|
||||
| "merge:audit-failure"
|
||||
| "branch:auto-reclaim"
|
||||
| "branch:auto-canonicalize-case"
|
||||
|
||||
Reference in New Issue
Block a user