Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-w
import type { buildCodingTaskSpec } from "./coding-task-spec.js";
import type { resolveAmsPolicy } from "./ams-policy.js";
import type { checkMinerKillSwitch } from "./governor-kill-switch.js";
import type { resolveMinerGoalSpec } from "./miner-goal-spec.js";

type CommonAttemptResultFields = {
repoFullName: string;
Expand Down Expand Up @@ -76,6 +77,7 @@ export type RunAttemptOptions = {
buildCodingTaskSpec?: typeof buildCodingTaskSpec;
resolveAmsPolicy?: typeof resolveAmsPolicy;
checkMinerKillSwitch?: typeof checkMinerKillSwitch;
resolveMinerGoalSpec?: typeof resolveMinerGoalSpec;
runMinerAttempt?: typeof runMinerAttempt;
/** Invoked with the real structured result at every return point, in addition to (never instead of) the
* plain exit-code return -- the loop orchestrator's real hook into what actually happened. */
Expand Down
31 changes: 26 additions & 5 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
// runs, not just checks-and-reports-blocked.
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list):
// governor.killSwitchRepoPaused only checks the GLOBAL env-var kill switch, not yet a real per-repo
// `.gittensory-miner.yml` pause (the resolver exists, miner-goal-spec.js/#5255, not wired in HERE yet); and
// governor.convergenceInput is an honest first-attempt-shaped literal, not a real per-issue attempt-history
// query (attempt-log.js's schema has no repo+issue index, and reenqueue counts aren't tracked anywhere yet).

Expand All @@ -18,6 +16,7 @@ import { runSlopAssessment } from "./slop-assessment.js";
import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js";
import { executeLocalWrite } from "./execute-local-write.js";
import { openClaimLedger } from "./claim-ledger.js";
import { resolveMinerGoalSpec } from "./miner-goal-spec.js";
import { initEventLedger } from "./event-ledger.js";
import { initAttemptLog } from "./attempt-log.js";
import { initGovernorLedger } from "./governor-ledger.js";
Expand Down Expand Up @@ -131,7 +130,7 @@ export function buildAttemptDeps(env, ledgers) {
* SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real
* AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call
* runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`.
* See this file's header for the documented gaps (per-repo kill-switch pause, real convergence history).
* See this file's header for the documented gaps (real convergence history).
*/
export async function runAttempt(args, options = {}) {
const parsed = parseAttemptArgs(args);
Expand Down Expand Up @@ -161,6 +160,7 @@ export async function runAttempt(args, options = {}) {
let governorLedger = null;
let allocation = null;
let worktreeResult = null;
let claimedIssue = false;

try {
allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)();
Expand Down Expand Up @@ -332,8 +332,18 @@ export async function runAttempt(args, options = {}) {
}

const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env });

// Real per-repo pause (#5392): read straight from the already-cloned worktree's own .gittensory-miner.yml
// (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so
// this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used
// by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor
// chokepoint) -- the same two places the GLOBAL kill switch already reaches.
const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec;
const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath);
const repoPaused = minerGoalSpec.spec.killSwitch.paused;

const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
const killSwitchScope = checkKillSwitch({ env }).scope;
const killSwitchScope = checkKillSwitch({ env, repoPaused }).scope;

const loopInput = buildAttemptLoopInput({
codingTaskSpec,
Expand All @@ -347,7 +357,14 @@ export async function runAttempt(args, options = {}) {
amsPolicySpec: amsPolicy.spec,
branchRef: worktreeResult.branchName,
});
const governor = buildAttemptGovernorContext(env, amsPolicy.spec);
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused);

// Real soft-claim (#5393): recorded once we've committed to a real attempt (past feasibility), so a
// sibling miner process on this machine sees it via claimLedger.listClaims/listActiveClaims while this
// attempt is in flight. Released in `finally` on every terminal outcome -- mirrors the worktree
// allocation slot's own acquire-then-always-release pattern below.
claimLedger.claimIssue(parsed.repoFullName, parsed.issueNumber, `attempt:${attemptId}`);
claimedIssue = true;

const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt;
const result = await runAttemptPipeline(
Expand Down Expand Up @@ -422,6 +439,10 @@ export async function runAttempt(args, options = {}) {
const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree;
await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true);
}
// Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an
// unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would
// wrongly tell a sibling miner this issue is still in flight.
if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber);
if (allocation && allocator) allocator.release(attemptId);
allocator?.close();
claimLedger?.close();
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/attempt-input-builder.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CodingTaskSpecResult } from "./coding-task-spec.js";
export function buildAttemptGovernorContext(
env: Record<string, string | undefined>,
amsPolicySpec: AmsPolicySpec,
repoPaused?: boolean,
): AttemptGovernorContext;

export type BuildAttemptLoopInputInput = {
Expand Down
14 changes: 7 additions & 7 deletions packages/gittensory-miner/lib/attempt-input-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@jsonbored/
// same discipline as coding-task-spec.js's own composers.
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- explicitly left as real, narrow follow-ups):
// - governor.killSwitchRepoPaused is omitted (undefined). The real resolver exists (miner-goal-spec.js's
// resolveMinerGoalSpec, #5255) but isn't wired in HERE yet -- this composer only takes `env`, not a
// `repoPath` to read a real .gittensory-miner.yml from. Only the GLOBAL kill switch (env var) is checked
// until that follow-up lands; a per-repo pause silently can't be detected yet (fails open on that one
// axis only, matching checkMinerKillSwitch's own documented fallback for an omitted repoPaused).
// - governor.convergenceInput is a first-attempt-shaped literal ({ attempts: 0, consecutiveFailures: 0,
// reenqueues: 0, reachedDone: false }), not a real per-issue query. attempt-log.js's schema has no
// repo+issue index (attemptId embeds a timestamp, so it's not a stable group key), and reenqueue counts
Expand All @@ -27,14 +22,19 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@jsonbored/
* capUsage are deliberately omitted -- evaluateGovernorChokepointGatePersisted (#5134) auto-loads them from
* the persisted governor-state store when absent.
*
* `repoPaused` (#5392) is the caller's own resolved `MinerGoalSpec.killSwitch.paused` for the target repo
* (miner-goal-spec.js's resolveMinerGoalSpec) -- this composer stays pure and just threads whatever the
* caller already resolved through; passing nothing keeps the prior fails-open-on-that-axis-only behavior.
*
* @param {Record<string, string | undefined>} env
* @param {import("@jsonbored/gittensory-engine").AmsPolicySpec} amsPolicySpec
* @param {boolean} [repoPaused]
* @returns {import("./attempt-runner.js").AttemptGovernorContext}
*/
export function buildAttemptGovernorContext(env, amsPolicySpec) {
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused) {
return {
killSwitchGlobal: isGlobalMinerKillSwitch(env),
killSwitchRepoPaused: undefined,
killSwitchRepoPaused: repoPaused,
liveModeGlobalOptIn: isGlobalMinerLiveModeOptIn(env),
capLimits: amsPolicySpec.capLimits,
convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
Expand Down
13 changes: 13 additions & 0 deletions packages/gittensory-miner/lib/gate-verdict-poller.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@
// 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. */
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-miner/lib/loop-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EventLedger } from "./event-ledger.js";
import type { GovernorLedger } from "./governor-ledger.js";
import type { RunStateStore } from "./run-state.js";
import type { PollPrDispositionOptions } from "./pr-disposition-poller.js";
import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js";

export type ParsedLoopArgs =
| { error: string }
Expand All @@ -30,6 +31,7 @@ export type LoopCycleSummary = {
attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error";
reentryOutcome?: "merged" | "disengaged" | "other";
prNumber?: number | null;
ciConclusion?: CheckRunConclusion | null;
reentered?: boolean;
reasons?: string[];
};
Expand All @@ -51,11 +53,13 @@ export type RunLoopOptions = {
checkMinerKillSwitch?: (input?: { env?: Record<string, string | undefined>; repoPaused?: boolean }) => { scope: "global" | "repo" | "none"; active: boolean };
evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { verdict: { reason: string }; canClaimNext: boolean };
pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number }>;
pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ conclusion: CheckRunConclusion; checks: unknown[]; headSha: string; attempts: number }>;
recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown;
buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { sinceSeq: number | null; lastSeq: number };
attemptLoopReentry?: (candidate: unknown, deps: unknown) => { decision: { reenter: boolean; reasons: string[] }; dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null };
attemptOptions?: Record<string, unknown>;
prDispositionOptions?: PollPrDispositionOptions;
ciPollOptions?: PollCheckRunsOptions;
};

export function runLoop(args: string[], options?: RunLoopOptions): Promise<number>;
28 changes: 26 additions & 2 deletions packages/gittensory-miner/lib/loop-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
// existed; this is the first caller that actually chains them into a real repeat-until-halted run.
//
// STRUCTURE (one cycle): kill-switch check -> real-per-repo-policy-aware run-loop boundary gate (before
// claiming) -> real runAttempt -> real PR-disposition poll (pr-disposition-poller.js, on a submitted outcome)
// -> real loop-closure summary -> real attemptLoopReentry decision. `attemptLoopReentry`'s own dequeue is the
// claiming) -> real runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll
// (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry
// decision. `attemptLoopReentry`'s own dequeue is the
// AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next
// candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which
// would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never-
Expand Down Expand Up @@ -35,6 +36,7 @@ import { runDiscover } from "./discover-cli.js";
import { runAttempt } from "./attempt-cli.js";
import { resolveAmsPolicy } from "./ams-policy.js";
import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js";
import { pollCheckRuns } from "./ci-poller.js";
import { recordPrOutcomeSnapshot } from "./pr-outcome.js";
import { buildLoopClosureSummary } from "./loop-closure.js";
import { attemptLoopReentry } from "./loop-reentry.js";
Expand Down Expand Up @@ -193,11 +195,13 @@ function zeroConvergence() {
* checkMinerKillSwitch?: typeof checkMinerKillSwitch,
* evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate,
* pollPrDisposition?: typeof pollPrDisposition,
* pollCheckRuns?: typeof pollCheckRuns,
* recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot,
* buildLoopClosureSummary?: typeof buildLoopClosureSummary,
* attemptLoopReentry?: typeof attemptLoopReentry,
* attemptOptions?: Record<string, unknown>,
* prDispositionOptions?: Record<string, unknown>,
* ciPollOptions?: Record<string, unknown>,
* }} [options]
* @returns {Promise<number>}
*/
Expand Down Expand Up @@ -234,6 +238,7 @@ export async function runLoop(args, options = {}) {
const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate;
const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition;
const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns;
const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot;
const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary;
const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry;
Expand Down Expand Up @@ -385,9 +390,27 @@ export async function runLoop(args, options = {}) {
let reentryOutcome = "other";
let prNumber = null;
let prDisposition = null;
let ciConclusion = null;
if (submitted) {
prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName);
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.
const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, {
githubToken,
apiBaseUrl: options.apiBaseUrl,
...(options.ciPollOptions ?? {}),
});
ciConclusion = ciStatus.conclusion;
eventLedger.appendEvent({
type: "ci_status_observed",
repoFullName: claimed.repoFullName,
payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" },
});

prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, {
githubToken,
apiBaseUrl: options.apiBaseUrl,
Expand Down Expand Up @@ -427,6 +450,7 @@ export async function runLoop(args, options = {}) {
attemptOutcome,
reentryOutcome,
prNumber,
ciConclusion,
reentered: reentry.decision.reenter,
reasons: reentry.decision.reasons,
});
Expand Down
Loading