Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion agents/ai-tfa-coordinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,17 @@ failing path, blame, deploy timing) via **GitHub MCP → `gh` → degrade**, and
each candidate suspect **try to disprove it** (path overlap? shipped before the
failure window? behind an OFF flag?). Feed both supporting *and* disconfirming
evidence back as a structured suspect packet; only `verdict: supported` suspects
belong in `related_prs`. Reuse the pre-computed build-level evidence — do not
belong in `related_prs`.

**Code-enforced validation gate (primary enforcement).** After the loop resolves,
`lib/loop.mjs`'s `out()` runs `validateAndDeduplicatePRs` (`lib/pr-validation.mjs`)
on every coordinator's `related_prs`, cross-checking each claimed PR against the
evidence file's `prsInWindow` ground truth. A PR absent from the evidence or
merged after `started_at` is automatically downgraded to
`verdict: "ruled-out (<reason>)"`. The LLM falsification protocol above is
**defense-in-depth** — it catches issues the code gate cannot (path-overlap,
behind-an-OFF-flag), but is not the sole enforcer of merge-window or existence
checks. Reuse the pre-computed build-level evidence — do not
re-fetch per test (the `evidenceFile`'s `github` section, if present and not
`gap`-marked for this repo; otherwise the live github connector). A culprit
hunt often needs to go deeper than the file's summary — a full diff, a
Expand Down
46 changes: 38 additions & 8 deletions lib/evidence-file.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,27 @@ function prKey(pr, index) {
return u ? `url:${u}` : t ? `title:${t}` : `anon:${index}`;
}

// Field-level merge for PR dedup collisions: keep the first non-null value
// per field across shards. Fixes author-loss when a later shard lacks a field
// an earlier one had (Fix B).
function mergePrEntry(existing, incoming) {
const merged = { ...incoming };
for (const key of Object.keys(existing)) {
if (merged[key] == null && existing[key] != null) {
merged[key] = existing[key];
}
}
return merged;
}

function foldGithub(target, repo, entry) {
const cur = target[repo] ?? { deployState: null, prsInWindow: [], gap: null };
const cur = target[repo] ?? { deployState: null, prsInWindow: [], gap: null, workingBranch: null };
const next = {
deployState: pickLeaf(cur.deployState, entry.deployState),
// Sticky, same as deployState: the build's actual working branch (from
// fetchBuildInsights), resolved once at Step 4. Lets pr-validation.mjs
// reject a PR merged into the wrong branch instead of assuming main/master.
workingBranch: pickLeaf(cur.workingBranch, entry.workingBranch),
prsInWindow: cur.prsInWindow ?? [],
// Sticky: once ANY writer has genuinely run the PR search, the entry stays
// trustworthy — a later contributor that didn't search must not silently
Expand All @@ -215,8 +232,18 @@ function foldGithub(target, repo, entry) {
};
if (Array.isArray(entry.prsInWindow)) {
const byPr = new Map((next.prsInWindow ?? []).map((p, i) => [prKey(p, i), p]));
entry.prsInWindow.forEach((pr, i) => byPr.set(prKey(pr, `in-${i}`), pr));
entry.prsInWindow.forEach((pr, i) => {
const key = prKey(pr, `in-${i}`);
const existing = byPr.get(key);
byPr.set(key, existing ? mergePrEntry(existing, pr) : pr);
});
next.prsInWindow = [...byPr.values()];
// FR-6: warn when a PR completes folding with no author from any shard.
for (const pr of next.prsInWindow) {
if (pr.author == null) {
console.warn(`[evidence-file] PR ${pr.pr ?? "?"} has author: null after fold — no shard provided an author`);
}
}
}
// A contributor supplying real content clears the pre-fetch's gap.
if (entry.gap === null || entry.gap === undefined) {
Expand Down Expand Up @@ -326,11 +353,13 @@ export function setBaseline(filePath, baseline, suspectWindow, nowMs) {
}

/** Read-modify-write merge into `doc.github[repo]`. `entry` shape:
* `{ deployState: {block, gap}, prsInWindow: [{pr, files, block, verdict}],
* gap }` — `gap` (top-level, on the repo entry) is what `recomputeCoverage`
* checks; a repo present with a non-null `gap` is NOT counted as covered.
* Only ever touches this one repo's key — every other repo/workload already
* in the file is untouched. */
* `{ deployState: {block, gap}, workingBranch, prsInWindow: [{pr, files,
* block, verdict, baseRefName, mergedAt}], gap }` — `workingBranch` is the
* build's actual branch (from fetchBuildInsights, resolved once at Step 4);
* `gap` (top-level, on the repo entry) is what `recomputeCoverage` checks; a
* repo present with a non-null `gap` is NOT counted as covered. Only ever
* touches this one repo's key — every other repo/workload already in the
* file is untouched. */
export function setGithubEvidence(filePath, repo, entry, nowMs) {
const doc = loadOrInit(filePath, nowMs);
doc.github[repo] = entry;
Expand Down Expand Up @@ -389,8 +418,9 @@ function writeShard(path, doc) {
* orchestrator's base pre-fetch. */
export function contributeGithubEvidence(basePath, writerId, repo, patch, nowMs) {
const { path, doc } = loadOwnShard(basePath, writerId, nowMs);
const entry = doc.github[repo] ?? { deployState: null, prsInWindow: [], gap: null };
const entry = doc.github[repo] ?? { deployState: null, prsInWindow: [], gap: null, workingBranch: null };
if (patch.deployState !== undefined) entry.deployState = patch.deployState;
if (patch.workingBranch !== undefined) entry.workingBranch = patch.workingBranch;
if (Array.isArray(patch.prsInWindow)) {
const byPr = new Map((entry.prsInWindow ?? []).map((p, i) => [prKey(p, i), p]));
patch.prsInWindow.forEach((pr, i) => byPr.set(prKey(pr, `in-${i}`), pr));
Expand Down
9 changes: 8 additions & 1 deletion lib/loop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
// PENDING instead would stack a second turn on one still in flight.

import { routeAsks } from "./routing.mjs";
import { validateAndDeduplicatePRs } from "./pr-validation.mjs";

// Drain budget for one soft-PENDING. Bounded so a wedged turn can never hang the
// batch — on exhaustion the loop still ends `PENDING` (resumable via the CSV's
Expand Down Expand Up @@ -145,6 +146,7 @@ export async function runRcaLoop({
config = {},
manifest = {},
gather = async () => "",
evidenceDoc,
turnCap = config?.turnCap ?? 6,
drain = config?.softPendingDrain,
sleep = defaultSleep,
Expand Down Expand Up @@ -172,13 +174,18 @@ export async function runRcaLoop({

const out = (status, turn, note) => {
const glimpse = turn?.glimpse ?? {};
const rawPrs = glimpse.related_prs ?? [];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Validation only runs for RESOLVED status

A FAILED or PENDING result with hallucinated related_prs skips validation. Likely intentional but worth documenting the design choice.

Reviewer: fallback

const validatedPrs =
status === "RESOLVED" && evidenceDoc
? validateAndDeduplicatePRs(rawPrs, evidenceDoc)
: rawPrs;
return {
testRunId: String(testRunId),
status,
confidence: turn?.confidence ?? "unknown",
root_cause: status === "RESOLVED" ? (glimpse.root_cause ?? "") : (note ?? ""),
failure_type: glimpse.failure_type ?? "",
related_prs: glimpse.related_prs ?? [],
related_prs: validatedPrs,
view_rca: turn?.viewRca ?? "",
threadId: threadId ?? null,
turnId: turnId ?? null,
Expand Down
135 changes: 135 additions & 0 deletions lib/pr-validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Code-enforced PR validation gate (Fix A).
//
// Every coordinator's `related_prs` exits through `loop.mjs` `out()`, which
// calls `validateAndDeduplicatePRs` here to cross-check each claimed PR
// against the evidence file's ground truth. A PR that fails validation is
// downgraded to `verdict: "ruled-out (<reason>)"` — never silently dropped.

/**
* Extract owner/repo from a PR entry's link or url field.
* Handles GitHub PR URLs like "https://github.com/owner/repo/pull/123".
*/
function extractOwnerRepo(entry) {
const raw = entry?.link || entry?.url || "";
const m = String(raw).match(/github\.com\/([^/]+\/[^/]+)\/pull\//);
return m ? m[1] : null;
}

/**
* Build the lookup key for a PR: "owner/repo" string.
* Prefers explicit owner+repo fields; falls back to parsing link/url.
*/
function resolveRepo(entry) {
if (entry?.owner && entry?.repo) return `${entry.owner}/${entry.repo}`;
return extractOwnerRepo(entry);
}

/** Strip a leading "#" so "#123" and 123 compare equal. */
function normalise(n) {
return String(n).replace(/^#/, "");
}

/**
* Validate a single suspect PR entry against the evidence file.
*
* @param {object} entry - PR entry with owner, repo, number, merged_at, link/url
* @param {object} evidenceDoc - folded evidence file from readEvidenceFile
* @returns {{valid: boolean, reason?: "not-in-evidence" | "shipped-after" | "wrong-base-branch"}}
*/
export function validateSuspectPR(entry, evidenceDoc) {
const repoKey = resolveRepo(entry);
if (!repoKey) return { valid: false, reason: "not-in-evidence" };

const repoEvidence = evidenceDoc?.github?.[repoKey];
const prsInWindow = repoEvidence?.prsInWindow ?? [];

const num = entry?.number ?? entry?.pr;
if (num == null) return { valid: false, reason: "not-in-evidence" };

const match = prsInWindow.find(
(p) => normalise(p.pr) === normalise(num),
);
if (!match) return { valid: false, reason: "not-in-evidence" };

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] normalise helper defined identically in two places

This same arrow function is also defined at line 90. Extract to module scope.

Suggested change
if (!match) return { valid: false, reason: "not-in-evidence" };
const normalise = (n) => String(n).replace(/^#/, "");

Reviewer: fallback


// Branch check: the PR's ground-truth baseRefName must match the build's
// ACTUAL working branch (resolved once at Step 4 from fetchBuildInsights) —
// never assumed to be main/master. A PR merged into the wrong branch could
// not have shipped to this build regardless of timing.
const workingBranch = repoEvidence?.workingBranch;
if (workingBranch && match.baseRefName && match.baseRefName !== workingBranch) {
return { valid: false, reason: "wrong-base-branch" };
}

// Merge-window check: merged_at must not be after the build's started_at.
const startedAt = evidenceDoc?.suspectWindow?.startedAt;
if (startedAt) {
const mergedMs = Date.parse(entry.merged_at ?? match.mergedAt ?? "");
const startedMs = Date.parse(startedAt);
if (!Number.isNaN(mergedMs) && !Number.isNaN(startedMs) && mergedMs > startedMs) {
return { valid: false, reason: "shipped-after" };
}
} else {
// Cannot falsify on window — log but allow.
console.warn(`[pr-validation] suspectWindow.startedAt absent — skipping merge-window check`);
}

return { valid: true };
}

/**
* Deduplicate and validate an array of suspect PR entries against the evidence file.
*
* - Deduplicates by (owner, repo, number).
* - Enriches each entry with ground-truth fields from the evidence file.
* - Valid entries keep their existing verdict.
* - Invalid entries are downgraded to `verdict: "ruled-out (<reason>)"`.
* - All entries (valid + ruled-out) are returned — nothing is silently dropped.
*
* @param {Array} relatedPrs - the TFA agent's claimed suspect PRs
* @param {object} evidenceDoc - folded evidence file from readEvidenceFile
* @returns {Array} deduplicated, validated PR entries
*/
export function validateAndDeduplicatePRs(relatedPrs, evidenceDoc) {
if (!Array.isArray(relatedPrs) || relatedPrs.length === 0) return [];

// Deduplicate by (repo, number).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Dedup keeps first entry, silently drops fields from later duplicates

The dedup loop keeps the first entry for a given repo+number key. If a later duplicate carries fields the first lacks (e.g. merged_at), those are silently lost. Ground-truth enrichment partially compensates but only for evidence-file fields.

Suggestion: Merge duplicates using a field-level merge (similar to mergePrEntry in evidence-file.mjs) rather than discarding later entries.

Reviewer: fallback

const seen = new Map();
for (const entry of relatedPrs) {
const repoKey = resolveRepo(entry);
const num = entry?.number ?? entry?.pr;
const dedup = repoKey && num != null
? `${repoKey}#${String(num).replace(/^#/, "")}`
: `anon-${seen.size}`;
if (!seen.has(dedup)) seen.set(dedup, entry);
}

const results = [];
for (const entry of seen.values()) {
const repoKey = resolveRepo(entry);
const num = entry?.number ?? entry?.pr;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Ground-truth enrichment can silently overwrite verdict

This spreads all non-null evidence fields over the entry. Currently safe only because evidence PRs lack a verdict field. Make the guarantee explicit.

Suggestion: Object.entries(groundTruth).filter(([k, v]) => v != null && k !== 'verdict')

Reviewer: fallback


// Enrich from evidence ground truth when possible.
const repoEvidence = repoKey ? evidenceDoc?.github?.[repoKey] : null;
const groundTruth = (repoEvidence?.prsInWindow ?? []).find(
(p) => num != null && normalise(p.pr) === normalise(num),
);

// Build the output entry: ground-truth fields override LLM transcription.
// Exclude `verdict` explicitly — a ground-truth PR entry has no business
// setting this coordinator-owned field, even if one ever appeared there.
const enriched = groundTruth
? { ...entry, ...Object.fromEntries(Object.entries(groundTruth).filter(([k, v]) => v != null && k !== "verdict")) }
: { ...entry };

const { valid, reason } = validateSuspectPR(entry, evidenceDoc);
if (!valid) {
enriched.verdict = `ruled-out (${reason})`;
console.warn(`[pr-validation] PR ${repoKey ?? "?"}#${num ?? "?"} downgraded: ${reason}`);
}
// Valid entries keep their existing verdict (typically "supported").

results.push(enriched);
}

return results;
}
118 changes: 116 additions & 2 deletions lib/repo-source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
// Measured: `gh api .../contents/<path>?ref=<sha>` ~1022ms; the same read as
// `git show <sha>:<path>` from a local clone ~37ms — 27x faster, and
// byte-identical. Across three real runs, file CONTENTS were 126 of 407 gh
// calls (31%) and commit history another 48 (12%), so this is the largest
// remaining slice of github traffic.
// calls (31%) and commit history another 48 (12%) — `commitHistoryAt` below
// closes that second slice the same way. `blameAt` has no `gh` equivalent to
// measure against at all: blame requires no GitHub dependency once the
// commit is present, not just a faster path to the same answer.
//
// THE CORRECTNESS RULE: ALWAYS PIN TO A COMMIT SHA, NEVER A BRANCH NAME.
//
Expand Down Expand Up @@ -171,3 +173,115 @@ export function readFileAt({ repo, sha, path, workspaceRoot, branch, allowFetch
return { ok: false, source: "remote-needed", reason: msg.slice(0, 200) };
}
}

/** Ensure `sha` is present locally, respecting `allowFetch` exactly like
* `readFileAt`. Returns `null` on success (nothing to report) or a
* `remote-needed` envelope on failure — shared by every helper below so the
* "not present, and don't fetch unless asked" contract stays in one place. */
function ensurePresent(dir, sha, branch, allowFetch) {
if (hasCommit(dir, sha)) return null;
if (!allowFetch) return { ok: false, source: "remote-needed", reason: `commit ${sha} not present in ${dir} (pass allowFetch to fetch it once)` };
if (!ensureCommit(dir, sha, branch)) return { ok: false, source: "remote-needed", reason: `commit ${sha} still absent after fetch` };
return null;
}

/**
* Commit history touching `path` in the range `fromSha..toSha` (exclusive of
* `fromSha`, inclusive of `toSha` — same semantics as `git log A..B`). Local
* equivalent of `gh api "repos/.../commits?sha=<branch>"`, the second-largest
* slice of `gh` traffic per this module's header (12% across three real
* runs) with no local-clone path until now.
*
* Returns `{ ok, source: "local"|"remote-needed", commits: [{sha, date,
* subject}], reason }`. Omit `path` to get the repo-wide history for the
* range. Both `fromSha` and `toSha` must be commit shas — same "never a
* branch name" rule as `readFileAt`, for the same reason: a branch-scoped
* range silently drifts as the branch moves.
*/
export function commitHistoryAt({ repo, fromSha, toSha, path, workspaceRoot, branch, allowFetch = false }) {
if (!SHA.test(String(fromSha ?? "")) || !SHA.test(String(toSha ?? ""))) {
return { ok: false, source: "remote-needed", reason: `fromSha and toSha must both be commit shas, got ${JSON.stringify(fromSha)}..${JSON.stringify(toSha)}` };
}
const dir = localCloneFor(repo, workspaceRoot);
if (!dir) return { ok: false, source: "remote-needed", reason: `no local clone of ${repo} under ${workspaceRoot}` };

for (const sha of [fromSha, toSha]) {
const missing = ensurePresent(dir, sha, branch, allowFetch);
if (missing) return missing;
}

try {
// \x1f (unit separator) can't appear in a commit subject, so it is a safe
// field delimiter without needing to escape/parse quoted output.
const args = ["log", "--pretty=format:%H\x1f%cI\x1f%s", `${fromSha}..${toSha}`];
if (path) args.push("--", path);
const raw = git(dir, args);
const commits = raw
? raw.split("\n").filter(Boolean).map((line) => {
const [sha, date, subject] = line.split("\x1f");
return { sha, date, subject };
})
: [];
return { ok: true, source: "local", commits, dir };
} catch (err) {
return { ok: false, source: "remote-needed", reason: String(err.stderr ?? err.message ?? "").slice(0, 200) };
}
}

function parseLinePorcelainBlame(raw) {
const out = [];
let cur = null;
for (const line of raw.split("\n")) {
const header = line.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/);
if (header) {
if (cur) out.push(cur);
cur = { sha: header[1], line: Number(header[2]), author: null, date: null, content: "" };
continue;
}
if (!cur) continue;
if (line.startsWith("author ")) cur.author = line.slice("author ".length);
else if (line.startsWith("author-time ")) cur.date = new Date(Number(line.slice("author-time ".length)) * 1000).toISOString();
else if (line.startsWith("\t")) cur.content = line.slice(1);
}
if (cur) out.push(cur);
return out;
}

/**
* Blame `path` at a pinned commit — "who/what last changed the failing
* line," the first ask in `references/github-evidence.md`'s evidence table.
* Pure local git plumbing: unlike every other helper here this isn't just
* faster than the network, there IS no equivalent single `gh` call — blame
* requires no GitHub dependency at all once the commit is present.
*
* `lineRange` is 1-indexed and inclusive, e.g. `{start: 40, end: 55}` from a
* stack frame; omit it to blame the whole file (expensive on a large file —
* always scope to the failing lines when you have them).
*
* Returns `{ ok, source: "local"|"remote-needed", lines: [{sha, author,
* date, line, content}], reason }`.
*/
export function blameAt({ repo, sha, path, lineRange, workspaceRoot, branch, allowFetch = false }) {
if (!SHA.test(String(sha ?? ""))) {
return { ok: false, source: "remote-needed", reason: `ref must be a commit sha, got ${JSON.stringify(sha)} (a branch name can silently blame stale code)` };
}
const dir = localCloneFor(repo, workspaceRoot);
if (!dir) return { ok: false, source: "remote-needed", reason: `no local clone of ${repo} under ${workspaceRoot}` };

const missing = ensurePresent(dir, sha, branch, allowFetch);
if (missing) return missing;

const args = ["blame", "--line-porcelain"];
if (lineRange?.start && lineRange?.end) args.push("-L", `${lineRange.start},${lineRange.end}`);
args.push(sha, "--", path);

try {
return { ok: true, source: "local", lines: parseLinePorcelainBlame(git(dir, args)), dir };
} catch (err) {
const msg = String(err.stderr ?? err.message ?? "");
if (/no such path|does not exist|is outside repository/i.test(msg)) {
return { ok: false, source: "local", reason: `path not present at ${sha}: ${path}` };
}
return { ok: false, source: "remote-needed", reason: msg.slice(0, 200) };
}
}
Loading