feat(engine): post-session branch attribution audit

Contamination on fusion/<id> branches (FN-5233 was the recent example:
two untrailered feat(FN-5353): commits sitting on fusion/fn-5233) used
to be invisible until merge time, days after it happened. The executor
already runs assertCleanBranchAtBase at worktree acquisition and reclaim
— the gap was the active session window itself.

Add reportBranchAttribution(repoDir, branch, baseSha, taskId) which
walks base..branch and bins every commit into ownTrailed (healthy),
ownUntrailed (subject tag but commit-msg hook didn't fire), foreign
(different FN-id), or unattributed (no subject pattern, no trailer —
typically a hand-merge or plumbing commit). Wire it into the executor
right after captureModifiedFiles in the post-session path: when any
anomaly bucket is non-empty, emit a structured branch:attribution-
anomaly audit event and a task log entry. The audit itself is wrapped
in a try/catch so a probe failure never destabilizes a completing
session. New branch:attribution-anomaly and branch:auto-reattach-
authoritative GitMutationType variants accept the structured metadata
(the latter for the handoff re-attach added earlier this session).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-21 19:20:33 -07:00
parent a8715ed963
commit 5848606a9a
5 changed files with 177 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": patch
---
Engine reliability: post-session branch-attribution audit catches contamination within minutes instead of days.
- The executor already checks branch contamination at *acquisition* time (`assertCleanBranchAtBase`) and at *reclaim* time. The gap was the *active session window* itself: commits added to `fusion/<id>` between acquisition and merge handoff went undetected until merge-time refusal, which is how FN-5233 ended up with two untrailered `feat(FN-5353):` commits sitting on `fusion/fn-5233`.
- New `reportBranchAttribution(repoDir, branch, baseSha, taskId)` walks `base..branch` and classifies every commit into four buckets: `ownTrailed` (subject tag + `Fusion-Task-Id` trailer — healthy), `ownUntrailed` (subject tag but missing trailer — signals the commit-msg hook didn't fire), `foreign` (different FN-id via subject or trailer — contamination), and `unattributed` (neither — typically a hand-merge or plumbing commit).
- Wired into the executor's post-session path (right after `captureModifiedFiles`): if any anomaly bucket is non-empty, the executor logs a structured `branch:attribution-anomaly` audit event and a task log entry. Failures in the audit itself are caught and warn-only — the audit must never destabilize a completing session. New `branch:attribution-anomaly` and `branch:auto-reattach-authoritative` git-mutation types accept the structured metadata.
- Five new vitest cases cover the four anomaly buckets and the empty-range no-op.

View File

@@ -48,6 +48,7 @@ import {
assertCleanBranchAtBase,
inspectBranchConflict,
listUniqueBranchCommits,
reportBranchAttribution,
} from "../branch-conflicts.js";
const mockedExecSync = vi.mocked(execSync);
@@ -465,5 +466,65 @@ describe("branch-conflicts", () => {
await expect(assertion).resolves.toBeUndefined();
});
describe("reportBranchAttribution", () => {
const RS = "\x1e";
const FS = "\x1f";
function setupLog(records: { sha: string; subject: string; body: string }[]) {
const log = records.map((r) => `${r.sha}${FS}${r.subject}${FS}${r.body}${RS}`).join("");
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git log --format=%H%x1f%s%x1f%b%x1e")) {
return Buffer.from(log);
}
throw new Error(`Unexpected command: ${command}`);
});
}
it("counts own-trailed commits as healthy", async () => {
setupLog([
{ sha: "aaa", subject: "feat(FN-1): add x", body: "Fusion-Task-Id: FN-1\n" },
{ sha: "bbb", subject: "fix(FN-1): tweak", body: "Fusion-Task-Id: FN-1\n" },
]);
const r = await reportBranchAttribution("/tmp/repo", "fusion/fn-1", "main", "FN-1");
expect(r.ownTrailed).toBe(2);
expect(r.ownUntrailed).toEqual([]);
expect(r.foreign).toEqual([]);
expect(r.unattributed).toEqual([]);
});
it("flags FN-5233-class foreign commits", async () => {
setupLog([
{ sha: "fff", subject: "feat(FN-5353): wire something", body: "" },
{ sha: "ggg", subject: "feat(FN-1): legit", body: "Fusion-Task-Id: FN-1\n" },
]);
const r = await reportBranchAttribution("/tmp/repo", "fusion/fn-1", "main", "FN-1");
expect(r.foreign).toEqual([{ sha: "fff", subject: "feat(FN-5353): wire something", foreignTaskId: "FN-5353" }]);
expect(r.ownTrailed).toBe(1);
});
it("flags own-but-untrailed commits (hook didn't fire)", async () => {
setupLog([
{ sha: "ccc", subject: "feat(FN-1): no trailer", body: "" },
]);
const r = await reportBranchAttribution("/tmp/repo", "fusion/fn-1", "main", "FN-1");
expect(r.ownUntrailed).toEqual([{ sha: "ccc", subject: "feat(FN-1): no trailer" }]);
expect(r.ownTrailed).toBe(0);
});
it("flags unattributed commits (no subject pattern, no trailer)", async () => {
setupLog([
{ sha: "ddd", subject: "hand-merge", body: "" },
]);
const r = await reportBranchAttribution("/tmp/repo", "fusion/fn-1", "main", "FN-1");
expect(r.unattributed).toEqual([{ sha: "ddd", subject: "hand-merge" }]);
});
it("returns empty report when range is empty", async () => {
setupLog([]);
const r = await reportBranchAttribution("/tmp/repo", "fusion/fn-1", "main", "FN-1");
expect(r).toEqual({ ownTrailed: 0, ownUntrailed: [], foreign: [], unattributed: [] });
});
});
});

View File

@@ -275,6 +275,75 @@ async function summarizeTaskAttributedCommits(repoDir: string, range: string, ta
return { ownCount, foreignCount };
}
export interface BranchAttributionReport {
/** Commits whose subject matches `<type>(<taskId>):` AND carry the trailer. */
ownTrailed: number;
/** Commits attributed to taskId via subject but missing the Fusion-Task-Id trailer
* (signals: hook didn't fire — worktree was used without identity guards). */
ownUntrailed: { sha: string; subject: string }[];
/** Commits attributed to a different FN-id via subject or trailer (contamination). */
foreign: { sha: string; subject: string; foreignTaskId: string }[];
/** Commits with neither a conventional subject nor any trailer (orphaned writes). */
unattributed: { sha: string; subject: string }[];
}
/**
* Post-session audit of every commit in `base..branch`. Used by the executor
* immediately after a step-session completes to detect three classes of
* contamination early — long before merge time:
*
* 1. ownUntrailed: agent committed legitimately but the commit-msg hook
* didn't fire (missing fusion-task-id, --no-verify, plumbing commit).
* 2. foreign: another task's work landed on this branch (FN-5233 pattern).
* 3. unattributed: commit lacks both subject prefix and trailer (often a
* hand-merged commit or plumbing-driven update).
*
* Returns counts/details rather than throwing so callers can decide whether
* to refuse, warn, or just audit.
*/
export async function reportBranchAttribution(
repoDir: string,
branch: string,
baseSha: string,
taskId: string,
): Promise<BranchAttributionReport> {
const report: BranchAttributionReport = { ownTrailed: 0, ownUntrailed: [], foreign: [], unattributed: [] };
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b%x1e ${quoteShellArg(`${baseSha}..${branch}`)}`)
.catch(() => "");
if (!output) return report;
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`, "i");
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}\\s*(?:\\n|$)`, "i");
const genericSubjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const genericTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*(FN-\\d+)\\s*(?:\\n|$)`, "i");
const normalizedTaskId = taskId.toUpperCase();
for (const record of output.split("").map((entry) => entry.trim()).filter(Boolean)) {
const [sha = "", subject = "", body = ""] = record.split("");
const subjectMatch = subject.match(genericSubjectPattern);
const trailerMatch = body.match(genericTrailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (attributedTaskId && attributedTaskId !== normalizedTaskId) {
report.foreign.push({ sha, subject, foreignTaskId: attributedTaskId });
continue;
}
if (!attributedTaskId) {
report.unattributed.push({ sha, subject });
continue;
}
const trailerPresent = ownTrailerPattern.test(body);
const subjectPresent = ownSubjectPattern.test(subject);
if (subjectPresent && trailerPresent) {
report.ownTrailed += 1;
} else if (subjectPresent && !trailerPresent) {
report.ownUntrailed.push({ sha, subject });
} else {
// trailer present, subject not — counts as trailed-own.
report.ownTrailed += 1;
}
}
return report;
}
/**
* True iff `branch`'s tip commit carries a `Fusion-Task-Id: <taskId>` trailer.
* Used as the cheap "is this branch ref authoritative for this task" probe

View File

@@ -70,6 +70,7 @@ import {
isBranchConflictError,
reanchorBranchToBase,
inspectBranchConflict,
reportBranchAttribution,
} from "./branch-conflicts.js";
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
@@ -3386,6 +3387,40 @@ export class TaskExecutor {
await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: modifiedFiles } });
}
// Post-session branch attribution audit: walk base..branch and surface
// any commit that's foreign (different FN-id), unattributed (no subject
// tag AND no Fusion-Task-Id trailer), or own-but-untrailed (signals the
// commit-msg hook didn't fire — typically a worktree without identity
// guards or a plumbing-driven commit). Logged loudly so contamination
// gets caught within minutes of happening rather than days later at
// merge time (FN-5233 was this pattern).
try {
const attributionBase = await this.resolveContaminationBaseRef(worktreePath);
if (attributionBase && updatedTask.branch) {
const attribution = await reportBranchAttribution(this.rootDir, updatedTask.branch, attributionBase, task.id);
const hasAnomaly = attribution.foreign.length > 0 || attribution.unattributed.length > 0 || attribution.ownUntrailed.length > 0;
if (hasAnomaly) {
const summary = `branch-attribution anomalies on ${updatedTask.branch}: foreign=${attribution.foreign.length}, unattributed=${attribution.unattributed.length}, ownUntrailed=${attribution.ownUntrailed.length}, ownTrailed=${attribution.ownTrailed}`;
executorLog.warn(`${task.id}: ${summary}`);
await this.store.logEntry(task.id, `[branch-attribution] ${summary}`, undefined, this.getRunContextFor(task.id));
await audit.git({
type: "branch:attribution-anomaly",
target: updatedTask.branch,
metadata: {
taskId: task.id,
baseSha: attributionBase,
ownTrailed: attribution.ownTrailed,
foreign: attribution.foreign,
unattributed: attribution.unattributed,
ownUntrailed: attribution.ownUntrailed,
},
});
}
}
} catch (attributionErr: unknown) {
executorLog.warn(`${task.id}: post-session branch-attribution audit failed: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`);
}
this.scheduleCompletedTaskWatchdog(task.id, "step-session completion");
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) {
return;

View File

@@ -228,6 +228,8 @@ export type GitMutationType =
// reserved; refusal currently thrown pre-audit
| "project:bootstrap-refused-linked-worktree"
| "branch:reanchor"
| "branch:attribution-anomaly"
| "branch:auto-reattach-authoritative"
/**
* Metadata shape:
* ```ts