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:
Fusion (runfusion.ai)
2026-05-21 14:34:12 -07:00
committed by gsxdsm
parent caeb6a7e00
commit 5a76a89071
13 changed files with 541 additions and 69 deletions

View File

@@ -313,6 +313,8 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
10. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 1–3 cascade; worktrunk-enabled paths defer this layer.
11. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
Audit verification surface for FN-5348/FN-5349/FN-5350 invariants: `merge:integration-worktree-state` (captures integration checkout/dirty state and selected integration mode before handoff), `merge:cwd-integration-fallback-refused` (records terminal park when reuse handoff refusal cannot be recovered), and `merge:integration-ref-advance` (records every integration ref advance attempt outcome with resolved branch/ref metadata).
### Gitignored-path guard on squash merges
The merger strips gitignored paths from staged squash sets before commit (standard, Attempt 3 fallback, and verification-fix rebuild). Any staged ignored path is unstaged and logged.
@@ -431,6 +433,9 @@ Reusable quality gates at configurable lifecycle phases. **Pre-merge** can block
Every engine mutation is recorded across four domains:
- **Database** — task:create, task:update, task:move, `room:ambiguity:branch` (deictic message routing telemetry), etc.
- **Git** — worktree:create, commit:create, merge:resolve, etc.
- `merge:cwd-integration-fallback-refused` — terminal reuse-handoff refusal path that parks in-review without cwd fallback.
- `merge:integration-ref-advance` — typed integration ref update outcome (`succeeded`/`error`) with resolved ref metadata.
- `merge:integration-worktree-state` — per-merge snapshot of resolved integration branch checkout and dirty/untracked state.
- **Filesystem** — file:write, prompt:write, attachment:create, `secret:read|create|update|delete|approval-requested|approval-granted|approval-denied|sync-push|sync-pull`, `secret:env-*`, etc.
- **Sandbox** — `sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`.

View File

@@ -1055,7 +1055,7 @@ Mesh configuration and post-provision managed-node operations are registered sep
### Run Audit API
The run-audit system records every mutation performed by the engine across four domains:
- **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued). Scheduler dispatch contention also emits `scheduler:dispatch-queued-concurrency` (debounced per task+reason): metadata includes `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`.
- **Git** — worktree:create, commit:create, merge:resolve, merge:audit-failure, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility.
- **Git** — worktree:create, commit:create, merge:resolve, merge:audit-failure, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry).
- **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360).
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.
- **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`).
@@ -1566,7 +1566,7 @@ The GitHub tracking state listener now attaches to every registered project stor
### Merge strategies
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
- `aiMergeTask()` in `merger.ts` performs merge flow
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-main` preserves the legacy project-root path. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired).
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-main` preserves the legacy project-root path. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
- Supports workflow-step execution after merge (post-merge phase)
- Deterministic verification now runs a bootstrap preamble (`node scripts/ensure-test-artifacts.mjs`) before configured `testCommand`/`buildCommand`, then self-heals Vite `Failed to resolve entry for package "@fusion/..."` workspace-entry faults by rebuilding the missing package once and retrying the failed command. If that retry still reports the same missing-entry fault, merger raises a typed environment fault and `ProjectEngine` leaves the task in-review (no verificationFailureCount increment or in-progress bounce) so the next recovery sweep can retry after other runs rebuild artifacts.

View File

@@ -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();

View File

@@ -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",
}),
});
});
});

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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",
]);
});
});

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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"