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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user