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