Skip to content

Commit 3daf596

Browse files
committed
feat(miner): wire per-repo kill switch, real claim-ledger, and CI-status observation into the real attempt/loop pipeline
Closes #5392, Closes #5393, Closes #5394 - attempt-cli.js now resolves the real MinerGoalSpec from the already-cloned worktree and threads killSwitch.paused into both checkMinerKillSwitch and the governor context, closing the per-repo pause gap attempt-input-builder.js's own header had documented since #5132. - attempt-cli.js now records a real soft-claim via claim-ledger.js's claimIssue once an attempt passes feasibility, releasing it on every terminal outcome -- the ledger was previously never written to in the real pipeline, so freshness/dedup checks against it were always no-ops. - loop-cli.js polls real GitHub check-run status (ci-poller.js) for every submitted PR before the disposition poll, recording a ci_status_observed event and surfacing ciConclusion in --json cycle output. gate-verdict-poller.js (#4273) was the originally preferred source but has no real caller-reachable endpoint today (documented in its own header) -- ci-poller.js is the issue's own documented fallback.
1 parent 91b12a6 commit 3daf596

10 files changed

Lines changed: 399 additions & 18 deletions

packages/gittensory-miner/lib/attempt-cli.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-w
1111
import type { buildCodingTaskSpec } from "./coding-task-spec.js";
1212
import type { resolveAmsPolicy } from "./ams-policy.js";
1313
import type { checkMinerKillSwitch } from "./governor-kill-switch.js";
14+
import type { resolveMinerGoalSpec } from "./miner-goal-spec.js";
1415

1516
type CommonAttemptResultFields = {
1617
repoFullName: string;
@@ -76,6 +77,7 @@ export type RunAttemptOptions = {
7677
buildCodingTaskSpec?: typeof buildCodingTaskSpec;
7778
resolveAmsPolicy?: typeof resolveAmsPolicy;
7879
checkMinerKillSwitch?: typeof checkMinerKillSwitch;
80+
resolveMinerGoalSpec?: typeof resolveMinerGoalSpec;
7981
runMinerAttempt?: typeof runMinerAttempt;
8082
/** Invoked with the real structured result at every return point, in addition to (never instead of) the
8183
* plain exit-code return -- the loop orchestrator's real hook into what actually happened. */

packages/gittensory-miner/lib/attempt-cli.js

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
// runs, not just checks-and-reports-blocked.
88
//
99
// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list):
10-
// governor.killSwitchRepoPaused only checks the GLOBAL env-var kill switch, not yet a real per-repo
11-
// `.gittensory-miner.yml` pause (the resolver exists, miner-goal-spec.js/#5255, not wired in HERE yet); and
1210
// governor.convergenceInput is an honest first-attempt-shaped literal, not a real per-issue attempt-history
1311
// query (attempt-log.js's schema has no repo+issue index, and reenqueue counts aren't tracked anywhere yet).
1412

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

165165
try {
166166
allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)();
@@ -332,8 +332,18 @@ export async function runAttempt(args, options = {}) {
332332
}
333333

334334
const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env });
335+
336+
// Real per-repo pause (#5392): read straight from the already-cloned worktree's own .gittensory-miner.yml
337+
// (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so
338+
// this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used
339+
// by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor
340+
// chokepoint) -- the same two places the GLOBAL kill switch already reaches.
341+
const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec;
342+
const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath);
343+
const repoPaused = minerGoalSpec.spec.killSwitch.paused;
344+
335345
const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
336-
const killSwitchScope = checkKillSwitch({ env }).scope;
346+
const killSwitchScope = checkKillSwitch({ env, repoPaused }).scope;
337347

338348
const loopInput = buildAttemptLoopInput({
339349
codingTaskSpec,
@@ -347,7 +357,14 @@ export async function runAttempt(args, options = {}) {
347357
amsPolicySpec: amsPolicy.spec,
348358
branchRef: worktreeResult.branchName,
349359
});
350-
const governor = buildAttemptGovernorContext(env, amsPolicy.spec);
360+
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused);
361+
362+
// Real soft-claim (#5393): recorded once we've committed to a real attempt (past feasibility), so a
363+
// sibling miner process on this machine sees it via claimLedger.listClaims/listActiveClaims while this
364+
// attempt is in flight. Released in `finally` on every terminal outcome -- mirrors the worktree
365+
// allocation slot's own acquire-then-always-release pattern below.
366+
claimLedger.claimIssue(parsed.repoFullName, parsed.issueNumber, `attempt:${attemptId}`);
367+
claimedIssue = true;
351368

352369
const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt;
353370
const result = await runAttemptPipeline(
@@ -422,6 +439,10 @@ export async function runAttempt(args, options = {}) {
422439
const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree;
423440
await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true);
424441
}
442+
// Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an
443+
// unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would
444+
// wrongly tell a sibling miner this issue is still in flight.
445+
if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber);
425446
if (allocation && allocator) allocator.release(attemptId);
426447
allocator?.close();
427448
claimLedger?.close();

packages/gittensory-miner/lib/attempt-input-builder.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CodingTaskSpecResult } from "./coding-task-spec.js";
55
export function buildAttemptGovernorContext(
66
env: Record<string, string | undefined>,
77
amsPolicySpec: AmsPolicySpec,
8+
repoPaused?: boolean,
89
): AttemptGovernorContext;
910

1011
export type BuildAttemptLoopInputInput = {

packages/gittensory-miner/lib/attempt-input-builder.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@jsonbored/
66
// same discipline as coding-task-spec.js's own composers.
77
//
88
// KNOWN, DOCUMENTED GAPS (not fabricated -- explicitly left as real, narrow follow-ups):
9-
// - governor.killSwitchRepoPaused is omitted (undefined). The real resolver exists (miner-goal-spec.js's
10-
// resolveMinerGoalSpec, #5255) but isn't wired in HERE yet -- this composer only takes `env`, not a
11-
// `repoPath` to read a real .gittensory-miner.yml from. Only the GLOBAL kill switch (env var) is checked
12-
// until that follow-up lands; a per-repo pause silently can't be detected yet (fails open on that one
13-
// axis only, matching checkMinerKillSwitch's own documented fallback for an omitted repoPaused).
149
// - governor.convergenceInput is a first-attempt-shaped literal ({ attempts: 0, consecutiveFailures: 0,
1510
// reenqueues: 0, reachedDone: false }), not a real per-issue query. attempt-log.js's schema has no
1611
// repo+issue index (attemptId embeds a timestamp, so it's not a stable group key), and reenqueue counts
@@ -27,14 +22,19 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@jsonbored/
2722
* capUsage are deliberately omitted -- evaluateGovernorChokepointGatePersisted (#5134) auto-loads them from
2823
* the persisted governor-state store when absent.
2924
*
25+
* `repoPaused` (#5392) is the caller's own resolved `MinerGoalSpec.killSwitch.paused` for the target repo
26+
* (miner-goal-spec.js's resolveMinerGoalSpec) -- this composer stays pure and just threads whatever the
27+
* caller already resolved through; passing nothing keeps the prior fails-open-on-that-axis-only behavior.
28+
*
3029
* @param {Record<string, string | undefined>} env
3130
* @param {import("@jsonbored/gittensory-engine").AmsPolicySpec} amsPolicySpec
31+
* @param {boolean} [repoPaused]
3232
* @returns {import("./attempt-runner.js").AttemptGovernorContext}
3333
*/
34-
export function buildAttemptGovernorContext(env, amsPolicySpec) {
34+
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused) {
3535
return {
3636
killSwitchGlobal: isGlobalMinerKillSwitch(env),
37-
killSwitchRepoPaused: undefined,
37+
killSwitchRepoPaused: repoPaused,
3838
liveModeGlobalOptIn: isGlobalMinerLiveModeOptIn(env),
3939
capLimits: amsPolicySpec.capLimits,
4040
convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },

packages/gittensory-miner/lib/gate-verdict-poller.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@
1010
// gate verdict are two different signals a caller can record independently.
1111
//
1212
// Fully testable via injected `fetchFn`/`sleepFn` (mirrors `ci-poller.js`) — no real network in tests.
13+
//
14+
// UNWIRED (#5394 investigation): no production caller exists anywhere in this package, and the endpoint this
15+
// module was built to poll doesn't have a real match today. The only real route serving a contributor their
16+
// own open-PR state is GET /v1/contributors/:login/open-pr-monitor (src/api/routes.ts, backed by
17+
// buildContributorOpenPrMonitor, src/signals/contributor-open-pr-monitor.ts) — but its response shape is a
18+
// LIST of `{ repoFullName, number, classification: OpenPrWorkClassification, ... }` packets across every open
19+
// PR for that login, not the single decided `{ disposition | gateDisposition | verdict }` field this module's
20+
// own `readGateDisposition` expects for ONE targeted PR. `loop-cli.js`'s real CI/gate-status observation
21+
// (#5394) uses `ci-poller.js`'s real GitHub check-run polling instead — the documented fallback for exactly
22+
// this case. Wiring this module for real needs either a new single-PR gate-decision route or a rewrite of
23+
// `readGateDisposition`/`mapGateDisposition` against `open-pr-monitor`'s real `classification` vocabulary —
24+
// deliberately left as a separate follow-up rather than guessed at here.
1325

1426
/** The typed gate verdicts, decided ones first, `pending` (not-yet-decided) last. */
1527
export const GATE_VERDICTS = Object.freeze(["merge", "close", "hold", "pending"]);

packages/gittensory-miner/lib/loop-cli.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { EventLedger } from "./event-ledger.js";
55
import type { GovernorLedger } from "./governor-ledger.js";
66
import type { RunStateStore } from "./run-state.js";
77
import type { PollPrDispositionOptions } from "./pr-disposition-poller.js";
8+
import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js";
89

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

6165
export function runLoop(args: string[], options?: RunLoopOptions): Promise<number>;

packages/gittensory-miner/lib/loop-cli.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
// existed; this is the first caller that actually chains them into a real repeat-until-halted run.
55
//
66
// STRUCTURE (one cycle): kill-switch check -> real-per-repo-policy-aware run-loop boundary gate (before
7-
// claiming) -> real runAttempt -> real PR-disposition poll (pr-disposition-poller.js, on a submitted outcome)
8-
// -> real loop-closure summary -> real attemptLoopReentry decision. `attemptLoopReentry`'s own dequeue is the
7+
// claiming) -> real runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll
8+
// (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry
9+
// decision. `attemptLoopReentry`'s own dequeue is the
910
// AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next
1011
// candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which
1112
// would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never-
@@ -35,6 +36,7 @@ import { runDiscover } from "./discover-cli.js";
3536
import { runAttempt } from "./attempt-cli.js";
3637
import { resolveAmsPolicy } from "./ams-policy.js";
3738
import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js";
39+
import { pollCheckRuns } from "./ci-poller.js";
3840
import { recordPrOutcomeSnapshot } from "./pr-outcome.js";
3941
import { buildLoopClosureSummary } from "./loop-closure.js";
4042
import { attemptLoopReentry } from "./loop-reentry.js";
@@ -193,11 +195,13 @@ function zeroConvergence() {
193195
* checkMinerKillSwitch?: typeof checkMinerKillSwitch,
194196
* evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate,
195197
* pollPrDisposition?: typeof pollPrDisposition,
198+
* pollCheckRuns?: typeof pollCheckRuns,
196199
* recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot,
197200
* buildLoopClosureSummary?: typeof buildLoopClosureSummary,
198201
* attemptLoopReentry?: typeof attemptLoopReentry,
199202
* attemptOptions?: Record<string, unknown>,
200203
* prDispositionOptions?: Record<string, unknown>,
204+
* ciPollOptions?: Record<string, unknown>,
201205
* }} [options]
202206
* @returns {Promise<number>}
203207
*/
@@ -234,6 +238,7 @@ export async function runLoop(args, options = {}) {
234238
const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
235239
const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate;
236240
const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition;
241+
const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns;
237242
const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot;
238243
const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary;
239244
const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry;
@@ -385,9 +390,27 @@ export async function runLoop(args, options = {}) {
385390
let reentryOutcome = "other";
386391
let prNumber = null;
387392
let prDisposition = null;
393+
let ciConclusion = null;
388394
if (submitted) {
389395
prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName);
390396
if (prNumber !== null) {
397+
// Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted
398+
// PR's check-run state is captured even while it's still open, not just at its eventual merge/close.
399+
// gate-verdict-poller.js (#4273) was the originally preferred source for this signal but has no real
400+
// caller-reachable endpoint today (see its own header) -- ci-poller.js's real GitHub check-run
401+
// polling is the documented fallback for exactly this case.
402+
const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, {
403+
githubToken,
404+
apiBaseUrl: options.apiBaseUrl,
405+
...(options.ciPollOptions ?? {}),
406+
});
407+
ciConclusion = ciStatus.conclusion;
408+
eventLedger.appendEvent({
409+
type: "ci_status_observed",
410+
repoFullName: claimed.repoFullName,
411+
payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" },
412+
});
413+
391414
prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, {
392415
githubToken,
393416
apiBaseUrl: options.apiBaseUrl,
@@ -427,6 +450,7 @@ export async function runLoop(args, options = {}) {
427450
attemptOutcome,
428451
reentryOutcome,
429452
prNumber,
453+
ciConclusion,
430454
reentered: reentry.decision.reenter,
431455
reasons: reentry.decision.reasons,
432456
});

0 commit comments

Comments
 (0)