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:
10
.changeset/fix-post-session-branch-attribution-audit.md
Normal file
10
.changeset/fix-post-session-branch-attribution-audit.md
Normal 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.
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
assertCleanBranchAtBase,
|
assertCleanBranchAtBase,
|
||||||
inspectBranchConflict,
|
inspectBranchConflict,
|
||||||
listUniqueBranchCommits,
|
listUniqueBranchCommits,
|
||||||
|
reportBranchAttribution,
|
||||||
} from "../branch-conflicts.js";
|
} from "../branch-conflicts.js";
|
||||||
|
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
@@ -465,5 +466,65 @@ describe("branch-conflicts", () => {
|
|||||||
await expect(assertion).resolves.toBeUndefined();
|
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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,6 +275,75 @@ async function summarizeTaskAttributedCommits(repoDir: string, range: string, ta
|
|||||||
return { ownCount, foreignCount };
|
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.
|
* 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
|
* Used as the cheap "is this branch ref authoritative for this task" probe
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
isBranchConflictError,
|
isBranchConflictError,
|
||||||
reanchorBranchToBase,
|
reanchorBranchToBase,
|
||||||
inspectBranchConflict,
|
inspectBranchConflict,
|
||||||
|
reportBranchAttribution,
|
||||||
} from "./branch-conflicts.js";
|
} from "./branch-conflicts.js";
|
||||||
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
|
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
|
||||||
import { resolveIntegrationBranch } from "./integration-branch.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 } });
|
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");
|
this.scheduleCompletedTaskWatchdog(task.id, "step-session completion");
|
||||||
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) {
|
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export type GitMutationType =
|
|||||||
// reserved; refusal currently thrown pre-audit
|
// reserved; refusal currently thrown pre-audit
|
||||||
| "project:bootstrap-refused-linked-worktree"
|
| "project:bootstrap-refused-linked-worktree"
|
||||||
| "branch:reanchor"
|
| "branch:reanchor"
|
||||||
|
| "branch:attribution-anomaly"
|
||||||
|
| "branch:auto-reattach-authoritative"
|
||||||
/**
|
/**
|
||||||
* Metadata shape:
|
* Metadata shape:
|
||||||
* ```ts
|
* ```ts
|
||||||
|
|||||||
Reference in New Issue
Block a user