Skip to content

Post-merge completion closes issues across repository boundaries: a merged PR in another repo can close an unrelated factory issue #276

Description

@khaliqgant

Factory's post-merge "advance to Done" path can close an issue in one repository because a pull request merged in a different repository, and it has already closed real, unfixed work as complete. At least 8 issues were falsely closed; 2 more closures in the same sample were legitimate.

All resolutions in this report were measured on 2026-08-16 against origin/main at f13600f32107e499bfccc6f971f7863fe5822927. Absence claims decay — re-measure before relying on them.

Discovered during the PR-triage sweep of #178 / #146.

The consequence, first

The clearest instance is #211 — "Key dispatch claims on work-unit identity, not surface path."

That issue was closed as completed on 2026-08-12. Its defect is still on origin/main, verbatim, at src/orchestrator/batch-tracker.ts:292:

export const issueKey = (issue: IssueRef): string => `${issue.key}:${issue.uuid}:${issue.path}`

So the mechanism described below closed, as complete, the very ticket that describes why a bare locator is not an identity. This is #228's thesis ("one identity model behind the duplicate-claim, dual-attribution, and 403 symptoms") demonstrated on Factory's own reconciliation path.

Evidence

Every one of these issues was closed by a comment of the exact form:

Factory observed pull request #N merge and completed this issue.

That string is emitted from exactly one place — src/orchestrator/factory.ts:11922 — so the closures are unambiguously attributable to the code path analysed below.

As of 2026-08-16, the highest number in AgentWorkforce/factory across both the issue and PR spaces is #275. Cited numbers above that cannot exist in this repository in any form.

factory issue closed (UTC) cites resolves in factory as same number in AgentWorkforce/cloud
#211 2026-08-12 #274 did not exist in any form on the closure date — factory's high-water mark was then ~#236, so the citation pointed ~38 numbers into the future cloud#274 — chore: delete dead sandbox Dockerfiles
#221 2026-08-14 #1518 does not exist, any type (above factory's whole number space) cloud#1518 — neon: split runtime and migration DB
#139 2026-08-13 #2891 does not exist, any type (above factory's whole number space) cloud#2891 — fix(relayauth): dormant emergency source mint gate
#146 2026-08-13 #2895 does not exist, any type (above factory's whole number space) cloud#2895 — fix(relayauth): restore bounded mint and webhook recovery
#216 2026-08-13 #219 exists, but as an ISSUE, not a merged PR — "Stale-mount auto-refresh targets a stale directory", created 2026-08-07 cloud#219 is a PR
#145 2026-07-21 #1346 does not exist, any type (above factory's whole number space) cloud#1346 — Bump relayfile adapter-core trigger catalog
#143 2026-07-21 #1346 does not exist, any type (above factory's whole number space) same as above
#260 2026-08-16 #1533 does not exist, any type (above factory's whole number space) cloud#1533 — Revert #1532 (S6 wiring)
#222 2026-08-15 #250 real, merged — legitimate closure
#141 2026-07-20 #148 real, merged — legitimate closure

Two precision notes, because a reviewer who checks will otherwise find the discrepancy and stop reading:

Mechanism

This was read from the code, not inferred from the pairs above.

A merged-PR mount event knows exactly which repository it came from. src/orchestrator/factory.ts:11771 builds it:

const repo = `${parts.owner}/${parts.repo}`

and passes it down at :11833 → #advanceMergedPrToDone(snapshot, repo, record).

Inside #advanceMergedPrToDone (:11893) there are two paths:

  1. With a tracked in-flight record — repository-qualified and correct. The record lookup at :11877 filters on recordMatchesGithubRepo(record, eventRepo, this.#config.repos.org).
  2. Without a record (the agents have already been released) — the fallback at :11905:
const issue = await this.#findMergeAdvanceIssueForPr(snapshot)

repo is in scope on that line and is not passed. #findMergeAdvanceIssueForPr (:11963) takes only the snapshot, then scans every issue path from the configured source (#githubIssuePaths(), :11977) and scores each one with prSnapshotIssueMatchScore(snapshot, issue.key) (:11991). Nothing in that loop compares the issue's repository to the merged PR's repository.

The active contract has issueSource: "github" and 19 repositories in repos.names, so that scan spans all 19 — each with its own independent number space.

The scorer (:17166) is:

const prSnapshotIssueMatchScore = (snapshot: PullSnapshot, issueKey: string): number => {
  if (factoryBranchMatchesIssue(snapshot.headRef ?? '', issueKey)) return 30
  if (containsIssueKey(snapshot.title ?? '', issueKey)) return 20
  if (containsExplicitIssueReference(snapshot.body ?? '', issueKey)) return 10
  return 0
}

For a GitHub-native issue, issue.key is a bare number. containsExplicitIssueReference (src/issue-key-match.ts:20-31) then matches that bare number against the PR body via, among others:

#211(?!\d)
https?://github\.com/[^\s/]+/[^\s/]+/issues/211(?!\d)

The URL alternative accepts [^\s/]+/[^\s/]+any owner and any repository. A merged PR in cloud whose body references cloud's own #211 therefore scores against factory's issue #211.

Two aggravating details in the same function:

  • The in-flight scan de-duplicates score ties and bails out (babysitterPrDiscoveryAmbiguous, :11886). The fallback scan has no ambiguity guard — on a tie the first match silently wins.
  • The closure comment then reports snapshot.number (:11922), which is the other repository's PR number. That is why the citations look like they point at nonexistent factory PRs: they are real PR numbers, just not from this repository.

Open question

I have not reconstructed which specific merge event fired each individual closure — the mount projection history for those moments is not available to me, and several cited numbers resolve to PRs in other in-scope repos that merged months before the closure date (cloud#274 merged 2026-04-22; factory#211 closed 2026-08-12). So a re-projection or backfill of an already-merged PR is likely part of the trigger, and worth confirming. The missing repository qualification at :11905 is read directly from the code and does not depend on that reconstruction.

Suggested direction

Pass the known repo into #findMergeAdvanceIssueForPr and require the candidate issue's repository to equal the merged PR's repository — the same qualification the in-flight path already applies at :11877. Adding the fallback's own ambiguity guard would close the tie-break gap alongside it.

Per #228, the durable fix is that a claim belongs to the work unit, not to a bare locator: a number is only an identity once qualified by its repository.

Not done here

The 8 falsely-closed issues are being audited separately to determine whether each one's work actually landed on main. Nothing has been reopened — that decision is deliberately left to a human.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions