diff --git a/packages/gittensory-miner/lib/gate-verdict-poller.d.ts b/packages/gittensory-miner/lib/gate-verdict-poller.d.ts deleted file mode 100644 index 8de0be46d7..0000000000 --- a/packages/gittensory-miner/lib/gate-verdict-poller.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type GateVerdict = "merge" | "close" | "hold" | "pending"; - -export const GATE_VERDICTS: readonly GateVerdict[]; - -export function mapGateDisposition(disposition: unknown): GateVerdict; - -export function readGateDisposition(body: unknown): string | null; - -export type PollGateVerdictOptions = { - fetchFn?: (url: string, init: { headers: Record }) => Promise<{ ok: boolean; status?: number; json: () => Promise }>; - sleepFn?: (ms: number) => Promise; - headers?: Record; - maxAttempts?: number; - minIntervalMs?: number; - maxIntervalMs?: number; -}; - -export type GateVerdictPollResult = { - verdict: GateVerdict; - disposition: string | null; - attempts: number; - body: unknown; -}; - -export function pollGateVerdict( - url: string, - options?: PollGateVerdictOptions, -): Promise; diff --git a/packages/gittensory-miner/lib/gate-verdict-poller.js b/packages/gittensory-miner/lib/gate-verdict-poller.js deleted file mode 100644 index 6297a71aa2..0000000000 --- a/packages/gittensory-miner/lib/gate-verdict-poller.js +++ /dev/null @@ -1,115 +0,0 @@ -// Maintainer-gate verdict watcher (#4273): polls the gittensory API for the REAL gate disposition of one of -// the miner's own PRs and maps it to a small typed verdict. The authoritative gate verdict is server-internal -// `gate_decision` state — NOT a GitHub check-run — so `ci-poller.js`'s CI-check-run aggregate (which -// `manage-poll.js`'s `mapPollConclusionToGateVerdict` turns into a pass/block/advisory proxy) is only a -// heuristic. This reads the authoritative source instead. -// -// Read-only: targets the gittensory API (not api.github.com), so it needs NO GitHub token; any auth the polled -// endpoint requires for a contributor's OWN PR is passed via `options.headers` (none required for the public -// open-pr-monitor today). No writes. `ci-poller.js`'s CI-check-run polling is left untouched — CI state and -// gate verdict are two different signals a caller can record independently. -// -// Fully testable via injected `fetchFn`/`sleepFn` (mirrors `ci-poller.js`) — no real network in tests. -// -// UNWIRED (#5394 investigation): no production caller exists anywhere in this package, and the endpoint this -// module was built to poll doesn't have a real match today. The only real route serving a contributor their -// own open-PR state is GET /v1/contributors/:login/open-pr-monitor (src/api/routes.ts, backed by -// buildContributorOpenPrMonitor, src/signals/contributor-open-pr-monitor.ts) — but its response shape is a -// LIST of `{ repoFullName, number, classification: OpenPrWorkClassification, ... }` packets across every open -// PR for that login, not the single decided `{ disposition | gateDisposition | verdict }` field this module's -// own `readGateDisposition` expects for ONE targeted PR. `loop-cli.js`'s real CI/gate-status observation -// (#5394) uses `ci-poller.js`'s real GitHub check-run polling instead — the documented fallback for exactly -// this case. Wiring this module for real needs either a new single-PR gate-decision route or a rewrite of -// `readGateDisposition`/`mapGateDisposition` against `open-pr-monitor`'s real `classification` vocabulary — -// deliberately left as a separate follow-up rather than guessed at here. - -import { fetchWithRetry } from "./http-retry.js"; - -/** The typed gate verdicts, decided ones first, `pending` (not-yet-decided) last. */ -export const GATE_VERDICTS = Object.freeze(["merge", "close", "hold", "pending"]); - -/** - * Map a raw gate disposition string to one of {@link GATE_VERDICTS}. Liberal on synonyms; an unknown, empty, or - * missing disposition maps to `pending` (not-yet-decided) — never a false decided verdict. Pure. - * @param {unknown} disposition - * @returns {"merge" | "close" | "hold" | "pending"} - */ -export function mapGateDisposition(disposition) { - const d = typeof disposition === "string" ? disposition.trim().toLowerCase() : ""; - switch (d) { - case "merge": - case "merged": - case "approved": - case "auto_merge": - return "merge"; - case "close": - case "closed": - case "rejected": - case "auto_close": - return "close"; - case "hold": - case "held": - case "manual_review": - case "action_required": - case "flagged": - return "hold"; - default: - return "pending"; // pending / open / unknown / missing — not yet decided - } -} - -/** - * Read the gate disposition field from an API response body, tolerant of it living at `disposition`, - * `gateDisposition`, or `verdict`. Returns the raw string, or null when absent/non-string. Pure. - * @param {unknown} body - * @returns {string | null} - */ -export function readGateDisposition(body) { - if (!body || typeof body !== "object") return null; - const raw = body.disposition ?? body.gateDisposition ?? body.verdict ?? null; - return typeof raw === "string" ? raw : null; -} - -function backoffDelayMs(attemptIndex, minIntervalMs, maxIntervalMs) { - const exponent = Math.min(10, Math.max(0, attemptIndex)); - return Math.min(maxIntervalMs, minIntervalMs * 2 ** exponent); -} - -function positiveInt(value, fallback, min, max) { - const n = Number.isInteger(value) ? value : fallback; - return Math.min(max, Math.max(min, n)); -} - -/** - * Poll a gittensory gate-verdict endpoint until it returns a DECIDED verdict (merge/close/hold) or `maxAttempts` - * is exhausted, backing off exponentially while `pending`. Dependencies are injected (`fetchFn`, `sleepFn`) so - * this is fully unit-testable with no real network. Throws on a missing URL or a non-OK HTTP response. - * @param {string} url - * @param {{ fetchFn?: Function, sleepFn?: Function, headers?: Record, - * maxAttempts?: number, minIntervalMs?: number, maxIntervalMs?: number }} [options] - * @returns {Promise<{ verdict: string, disposition: string | null, attempts: number, body: unknown }>} - */ -export async function pollGateVerdict(url, options = {}) { - if (typeof url !== "string" || !url) throw new Error("invalid_gate_verdict_url"); - const fetchFn = options.fetchFn ?? fetch; - const sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); - const headers = options.headers ?? {}; - const maxAttempts = positiveInt(options.maxAttempts, 10, 1, 20); - const minIntervalMs = positiveInt(options.minIntervalMs, 2000, 1, 60 * 60_000); - const maxIntervalMs = positiveInt(options.maxIntervalMs, 60_000, 1, 60 * 60_000); - - let latest = { verdict: "pending", disposition: null, attempts: 0, body: null }; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - // Retry transient network errors / 5xx around this single call (#4829), distinct from the pending-retry loop. - const response = await fetchWithRetry(fetchFn, url, { headers }, { sleepFn }); - if (!response || !response.ok) throw new Error(`gate_verdict_http_${response ? response.status : "error"}`); - const body = await response.json(); - const disposition = readGateDisposition(body); - const verdict = mapGateDisposition(disposition); - latest = { verdict, disposition, attempts: attempt + 1, body }; - if (verdict !== "pending") return latest; // decided — stop - if (attempt === maxAttempts - 1) return latest; // exhausted while still pending - await sleepFn(backoffDelayMs(attempt, minIntervalMs, maxIntervalMs)); - } - return latest; -} diff --git a/packages/gittensory-miner/lib/http-retry.js b/packages/gittensory-miner/lib/http-retry.js index 86470966ad..3a564f5880 100644 --- a/packages/gittensory-miner/lib/http-retry.js +++ b/packages/gittensory-miner/lib/http-retry.js @@ -1,5 +1,5 @@ -// Bounded retry-with-backoff around a single HTTP call (#4829). The miner's pollers (ci-poller, gate-verdict- -// poller) previously let a single brief 5xx from GitHub kill the whole poll loop, because their own attempt loop +// Bounded retry-with-backoff around a single HTTP call (#4829). The miner's pollers (ci-poller and others) +// previously let a single brief 5xx from GitHub kill the whole poll loop, because their own attempt loop // only re-polls while a conclusion is genuinely "pending", never after a server error. This wraps ONE fetch so a // transient SERVER error (a 5xx RESPONSE) is retried a bounded number of times, DISTINCT from that pending- // polling, sleeping an exponential backoff between attempts and giving up after `maxAttempts`. A 2xx/3xx/4xx diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js index 8830b17358..fc446c20c0 100644 --- a/packages/gittensory-miner/lib/loop-cli.js +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -383,9 +383,9 @@ export async function runLoop(args, options = {}) { if (prNumber !== null) { // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. - // gate-verdict-poller.js (#4273) was the originally preferred source for this signal but has no real - // caller-reachable endpoint today (see its own header) -- ci-poller.js's real GitHub check-run - // polling is the documented fallback for exactly this case. + // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the + // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly + // from GitHub's own PR state rather than a server-internal endpoint (#5450). const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, { githubToken, apiBaseUrl: options.apiBaseUrl, diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 3d0942497f..e303439ad4 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-gate-verdict-poller.test.ts b/test/unit/miner-gate-verdict-poller.test.ts deleted file mode 100644 index ad7331959f..0000000000 --- a/test/unit/miner-gate-verdict-poller.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - GATE_VERDICTS, - mapGateDisposition, - readGateDisposition, - pollGateVerdict, -} from "../../packages/gittensory-miner/lib/gate-verdict-poller.js"; - -const URL = "https://api.gittensory.test/v1/contributors/dhgoal/open-pr-monitor"; -const okResponse = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); - -describe("gittensory-miner gate-verdict poller (#4273)", () => { - it("exposes the frozen verdict vocabulary", () => { - expect(GATE_VERDICTS).toEqual(["merge", "close", "hold", "pending"]); - expect(Object.isFrozen(GATE_VERDICTS)).toBe(true); - }); - - it("maps each disposition family (with synonyms, case-insensitive) to a verdict", () => { - expect(mapGateDisposition("merged")).toBe("merge"); - expect(mapGateDisposition("Approved")).toBe("merge"); - expect(mapGateDisposition("closed")).toBe("close"); - expect(mapGateDisposition("REJECTED")).toBe("close"); - expect(mapGateDisposition(" action_required ")).toBe("hold"); - expect(mapGateDisposition("flagged")).toBe("hold"); - }); - - it("maps unknown/missing/non-string dispositions to pending, never a false decided verdict", () => { - expect(mapGateDisposition("open")).toBe("pending"); - expect(mapGateDisposition("")).toBe("pending"); - expect(mapGateDisposition(undefined)).toBe("pending"); - expect(mapGateDisposition(42)).toBe("pending"); - }); - - it("reads the disposition from any of the tolerated field names, else null", () => { - expect(readGateDisposition({ disposition: "merge" })).toBe("merge"); - expect(readGateDisposition({ gateDisposition: "hold" })).toBe("hold"); - expect(readGateDisposition({ verdict: "closed" })).toBe("closed"); - expect(readGateDisposition({ other: "x" })).toBeNull(); - expect(readGateDisposition({ disposition: 7 })).toBeNull(); // non-string - expect(readGateDisposition(null)).toBeNull(); - }); - - it("throws on a missing URL", async () => { - await expect(pollGateVerdict("")).rejects.toThrow(/invalid_gate_verdict_url/); - }); - - it("returns a decided verdict on the first attempt without sleeping", async () => { - const fetchFn = vi.fn().mockResolvedValue(okResponse({ disposition: "merge" })); - const sleepFn = vi.fn().mockResolvedValue(undefined); - const result = await pollGateVerdict(URL, { fetchFn, sleepFn }); - expect(result).toMatchObject({ verdict: "merge", disposition: "merge", attempts: 1 }); - expect(sleepFn).not.toHaveBeenCalled(); - }); - - it("backs off while pending, then returns the decided verdict", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(okResponse({ disposition: "pending" })) - .mockResolvedValueOnce(okResponse({ disposition: "pending" })) - .mockResolvedValueOnce(okResponse({ disposition: "close" })); - const sleepFn = vi.fn().mockResolvedValue(undefined); - const result = await pollGateVerdict(URL, { fetchFn, sleepFn, minIntervalMs: 100, maxIntervalMs: 1000 }); - expect(result.verdict).toBe("close"); - expect(result.attempts).toBe(3); - expect(sleepFn).toHaveBeenCalledTimes(2); - expect(sleepFn).toHaveBeenNthCalledWith(1, 100); // 100 * 2^0 - expect(sleepFn).toHaveBeenNthCalledWith(2, 200); // 100 * 2^1 - }); - - it("stops at maxAttempts and returns the last pending verdict when never decided", async () => { - const fetchFn = vi.fn().mockResolvedValue(okResponse({ disposition: "pending" })); - const sleepFn = vi.fn().mockResolvedValue(undefined); - const result = await pollGateVerdict(URL, { fetchFn, sleepFn, maxAttempts: 3 }); - expect(result.verdict).toBe("pending"); - expect(result.attempts).toBe(3); - expect(fetchFn).toHaveBeenCalledTimes(3); - expect(sleepFn).toHaveBeenCalledTimes(2); // no sleep after the final attempt - }); - - it("throws on a non-OK HTTP response", async () => { - const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}) }); - await expect(pollGateVerdict(URL, { fetchFn, sleepFn: vi.fn() })).rejects.toThrow(/gate_verdict_http_503/); - }); -});