feat(pr): node-agnostic PR reconcile + hold-release firing (U4)

Adds PrReconciler — a per-repo, self-owned polling loop (started from the
runtime layer in project-engine.ts, NOT the scheduler) that ETag-probes
GitHub, deep-fetches on change, persists mirror state, clears unverified
on first reconcile, and fires releaseHeldTaskByEvent(github:pr-<event>)
for transitions (changes-requested/approved/conflict/conflict-cleared/
merged/closed). Drops terminal entities; persists an audit event on error.
GitHub ops injected via PrReconcileGithubOps at the 3 CLI sites; engine
never imports the dashboard client. scheduler.ts stays PR-free (R20),
pinned by a regression test. 8 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 20:05:29 -07:00
parent 4e16145aab
commit c13f9a123b
10 changed files with 906 additions and 1 deletions

View File

@@ -45,6 +45,7 @@ import {
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
createPrReconcileGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
@@ -340,6 +341,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
});

View File

@@ -55,6 +55,7 @@ import {
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
createPrReconcileGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -1616,6 +1617,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
getTaskMergeBlocker,
});

View File

@@ -45,6 +45,7 @@ import {
createGroupPrCallback,
syncGroupPrCallback,
createPrNodeGithubOps,
createPrReconcileGithubOps,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
@@ -366,6 +367,7 @@ export async function runServe(
createGroupPr: createGroupPrCallback(githubClient),
syncGroupPr: syncGroupPrCallback(githubClient),
prNodeGithubOps: createPrNodeGithubOps(githubClient),
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
getTaskMergeBlocker,
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
});

View File

@@ -27,7 +27,14 @@ import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool, PrNodeGithubOps } from "@fusion/engine";
import type {
CreateGroupPrFn,
SyncGroupPrFn,
WorktreePool,
PrNodeGithubOps,
PrReconcileGithubOps,
PrReconcileFetchResult,
} from "@fusion/engine";
/**
* Minimal interface for GitHub operations needed by the PR merge workflow.
@@ -47,6 +54,13 @@ interface GitHubOperations {
getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo>;
updatePr(params: { owner?: string; repo?: string; number: number; title?: string; body?: string }): Promise<PrInfo>;
closePr(params: { number: number }): Promise<PrInfo>;
/** ETag-conditional change probe (U2/U4); 304 ⇒ unchanged, rate-limit-free. */
probePrChanged(
owner: string | undefined,
repo: string | undefined,
number: number,
etag?: string,
): Promise<{ changed: boolean; etag?: string }>;
}
/**
@@ -387,6 +401,67 @@ export function createPrNodeGithubOps(
};
}
/**
* Parse the entity's `owner/repo` repo slug into its components, tolerating an
* empty/single-segment value (returns undefined owner/repo so the client falls
* back to its configured repo).
*/
function splitRepoSlug(repo: string): { owner: string | undefined; name: string | undefined } {
const [owner, name] = repo.split("/");
return { owner: owner || undefined, name: name || undefined };
}
/** Map a GitHub `PrStatus` to the reconcile fetch result's coarse PR state. */
function mapPrStatusToFetchState(status: PrInfo["status"]): "open" | "merged" | "closed" {
if (status === "merged") return "merged";
if (status === "closed") return "closed";
// "open" and "draft" both reconcile as open.
return "open";
}
/**
* Build the `prReconcileGithubOps` engine callbacks (U4) backing the
* node-agnostic {@link PrReconciler}. Closes over the dashboard `GitHubClient`
* so the engine never imports it (FN-3049), exactly like
* {@link createPrNodeGithubOps}. Wired at the same three CLI composition sites.
*
* - probe: ETag-conditional change probe (304 ⇒ unchanged ⇒ skip deep-fetch).
* - fetchPrState: deep-fetch the GitHub-corroborated mirror. A 404 (PR not
* found) maps to `{ exists: false }` so the reconcile clears fictional
* unverified entities (R19).
*/
export function createPrReconcileGithubOps(
github: Pick<GitHubOperations, "probePrChanged" | "getPrStatus">,
): PrReconcileGithubOps {
return {
probe: (repo, prNumber, etag) => {
const { owner, name } = splitRepoSlug(repo);
return github.probePrChanged(owner, name, prNumber, etag);
},
fetchPrState: async (repo, prNumber): Promise<PrReconcileFetchResult> => {
const { owner, name } = splitRepoSlug(repo);
let info: PrInfo;
try {
info = await github.getPrStatus(owner ?? "", name ?? "", prNumber);
} catch (err) {
// A 404 / "not found" means there is no PR behind this entity.
const message = err instanceof Error ? err.message : String(err);
if (/not found|404/i.test(message)) return { exists: false };
throw err;
}
return {
exists: true,
prState: mapPrStatusToFetchState(info.status),
prNumber: info.number,
prUrl: info.url,
mergeable: info.mergeable,
checksRollup: info.checkRollup,
reviewDecision: info.lastReviewDecision ?? null,
};
},
};
}
async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise<boolean> {
try {
const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 });