Skip to content

Commit 9c4d41f

Browse files
committed
feat(miner): build the autonomous supervising loop
Closes #5135 The final piece of Wave 3.5's Miner AMS epic (#5130): a real discover -> claim -> attempt -> observe -> re-enter loop, composing every existing primitive (runDiscover, runAttempt, the run-loop boundary gate, loop-reentry, loop-closure) into an actual repeat- until-halted CLI command. No daemon/watch pattern existed anywhere in this package before this change. New pieces: - lib/loop-cli.js: `gittensory-miner loop` -- fails closed if governor state can't be loaded; checks the kill switch and a real per-repo policy-aware run-loop boundary gate before every claim; runs a real attempt via runAttempt's onResult hook; on a submitted outcome, polls the real PR disposition and records it; tracks real in-memory convergence history and persists real GovernorCapUsage (turnsTaken from runMinerAttempt's own totalTurnsUsed, elapsedMs from wall-clock) via governor-state.js's saveCapUsage -- previously uncalled anywhere. A permanent AI-usage-policy block marks its item done instead of requeuing it forever; any other non-submitted outcome requeues, and a genuinely stuck item halts the whole run via real non-convergence detection rather than looping forever. - lib/pr-disposition-poller.js: polls a PR's real merge/close disposition (distinct from ci-poller.js's check-run polling) and classifies it into loop-reentry's merged/disengaged/other vocabulary -- the missing piece pr-outcome.js's store had no real caller for. - attempt-cli.js: surfaces runMinerAttempt's real loopResult turn usage (totalTurnsUsed/iterationsUsed) through options.onResult, so the loop can save genuine cap usage instead of a fabricated number. Documented, deliberate gaps (not silently papered over): convergence and cap-usage history are scoped to this loop process's own lifetime (cap usage itself persists across restarts; per-issue convergence counters do not -- a durable version needs attempt-log.js to grow a repo+issue index, a separate schema change); the loop's kill-switch and boundary checks are global-scope only, matching runAttempt's own existing gap (a per-repo `.gittensory-miner.yml` pause resolver now exists via #5255 but isn't wired into either call site yet).
1 parent 6f05c31 commit 9c4d41f

12 files changed

Lines changed: 1348 additions & 4 deletions

packages/gittensory-miner/bin/gittensory-miner.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { runDiscover } from "../lib/discover-cli.js";
66
import { runFeasibilityCli } from "../lib/feasibility-cli.js";
77
import { runGovernorCli } from "../lib/governor-ledger-cli.js";
88
import { runLedgerCli } from "../lib/event-ledger-cli.js";
9+
import { runLoop } from "../lib/loop-cli.js";
910
import { runManagePoll } from "../lib/manage-poll.js";
1011
import { runManageStatus } from "../lib/manage-status.js";
1112
import { runPlanCli } from "../lib/plan-store-cli.js";
@@ -133,6 +134,12 @@ if (cliArgs[0] === "attempt") {
133134
process.exit(exitCode);
134135
}
135136

137+
if (cliArgs[0] === "loop") {
138+
const exitCode = await runLoop(cliArgs.slice(1));
139+
await awaitOpportunisticUpdateCheck(updateCheck);
140+
process.exit(exitCode);
141+
}
142+
136143
const exitCode = runCli(cliArgs, { packageName });
137144
await awaitOpportunisticUpdateCheck(updateCheck);
138145
process.exit(exitCode);

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine";
2-
import type { AttemptDeps, runMinerAttempt } from "./attempt-runner.js";
1+
import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@jsonbored/gittensory-engine";
2+
import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt } from "./attempt-runner.js";
33
import type { ClaimLedger } from "./claim-ledger.js";
44
import type { EventLedger } from "./event-ledger.js";
55
import type { AttemptLog } from "./attempt-log.js";
@@ -12,6 +12,39 @@ 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";
1414

15+
type CommonAttemptResultFields = {
16+
repoFullName: string;
17+
issueNumber: number;
18+
minerLogin: string;
19+
base: string;
20+
mode: CodingAgentExecutionMode;
21+
attemptId: string;
22+
};
23+
24+
/** The result runAttempt reports at every real return point, threaded to `options.onResult` (in addition to
25+
* the plain exit-code return runAttempt itself still returns, unchanged, so bin/gittensory-miner.js's own
26+
* `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */
27+
export type AttemptCliResult =
28+
| (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string })
29+
| (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string })
30+
| (CommonAttemptResultFields & {
31+
outcome: "blocked_infeasible";
32+
reason: string;
33+
verdict: FeasibilityVerdict;
34+
avoidReasons: string[];
35+
raiseReasons: string[];
36+
})
37+
| (CommonAttemptResultFields & {
38+
outcome: `attempt_${RunMinerAttemptResult["outcome"]}`;
39+
submissionMode: "observe" | "enforce";
40+
totalTurnsUsed: number;
41+
iterationsUsed: number;
42+
reason?: string;
43+
decision?: unknown;
44+
spec?: LocalWriteActionSpec;
45+
execResult?: unknown;
46+
});
47+
1548
export type ParsedAttemptArgs =
1649
| { error: string }
1750
| { repoFullName: string; issueNumber: number; minerLogin: string; base: string; live: boolean; json: boolean };
@@ -43,6 +76,9 @@ export type RunAttemptOptions = {
4376
resolveAmsPolicy?: typeof resolveAmsPolicy;
4477
checkMinerKillSwitch?: typeof checkMinerKillSwitch;
4578
runMinerAttempt?: typeof runMinerAttempt;
79+
/** Invoked with the real structured result at every return point, in addition to (never instead of) the
80+
* plain exit-code return -- the loop orchestrator's real hook into what actually happened. */
81+
onResult?: (result: AttemptCliResult) => void;
4682
};
4783

4884
export function runAttempt(args: string[], options?: RunAttemptOptions): Promise<number>;

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export async function runAttempt(args, options = {}) {
207207
`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`,
208208
);
209209
}
210+
options.onResult?.(rejectedResult);
210211
return 5;
211212
}
212213

@@ -259,6 +260,7 @@ export async function runAttempt(args, options = {}) {
259260
} else {
260261
console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`);
261262
}
263+
options.onResult?.(worktreeFailureResult);
262264
return 6;
263265
}
264266

@@ -325,6 +327,7 @@ export async function runAttempt(args, options = {}) {
325327
`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`,
326328
);
327329
}
330+
options.onResult?.(infeasibleResult);
328331
return 4;
329332
}
330333

@@ -371,16 +374,23 @@ export async function runAttempt(args, options = {}) {
371374
mode,
372375
attemptId,
373376
submissionMode: amsPolicy.spec.submissionMode,
377+
// Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage to
378+
// save real GovernorCapUsage via governor-state.js's saveCapUsage -- nothing else in the codebase calls
379+
// it yet). Surfaced flat rather than the whole loopResult object, matching this result's own shallow shape.
380+
totalTurnsUsed: result.loopResult.totalTurnsUsed,
381+
iterationsUsed: result.loopResult.iterationsUsed,
374382
...("reason" in result ? { reason: result.reason } : {}),
375383
...("decision" in result ? { decision: result.decision } : {}),
376384
...("spec" in result ? { spec: result.spec } : {}),
385+
...("execResult" in result ? { execResult: result.execResult } : {}),
377386
};
378387

379388
if (parsed.json) {
380389
console.log(JSON.stringify(finalResult, null, 2));
381390
} else {
382391
console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`);
383392
}
393+
options.onResult?.(finalResult);
384394

385395
switch (result.outcome) {
386396
case "submitted":

packages/gittensory-miner/lib/cli.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export function printHelp(input) {
2222
" gittensory-miner discover <owner/repo> [<owner/repo>...] [--json]",
2323
" gittensory-miner discover --search <query> [--json] Fan out, rank, and enqueue candidates",
2424
" gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]",
25+
" gittensory-miner loop <owner/repo> [<owner/repo>...] --miner-login <login> [--base <branch>] [--live]",
26+
" gittensory-miner loop --search <query> --miner-login <login> [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]",
27+
" Autonomous discover->claim->attempt->reenter loop",
2528
" gittensory-miner queue list [--repo <owner/repo>] [--json] List portfolio backlog rows",
2629
" gittensory-miner queue next [--json] Claim the highest-priority queued item",
2730
" gittensory-miner queue claim-batch [--global-wip <n>] [--per-repo-wip <n>] [--json]",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { AttemptCliResult } from "./attempt-cli.js";
2+
import type { PortfolioQueueStore } from "./portfolio-queue.js";
3+
import type { GovernorState } from "./governor-state.js";
4+
import type { EventLedger } from "./event-ledger.js";
5+
import type { GovernorLedger } from "./governor-ledger.js";
6+
import type { RunStateStore } from "./run-state.js";
7+
import type { PollPrDispositionOptions } from "./pr-disposition-poller.js";
8+
9+
export type ParsedLoopArgs =
10+
| { error: string }
11+
| {
12+
targets: string[];
13+
search: string | null;
14+
minerLogin: string;
15+
base: string;
16+
live: boolean;
17+
maxCycles: number | undefined;
18+
cycleDelayMs: number;
19+
json: boolean;
20+
};
21+
22+
export function parseLoopArgs(args: string[]): ParsedLoopArgs;
23+
24+
export type LoopCycleSummary = {
25+
cycle: number;
26+
outcome: "idle_queue_empty" | "halted" | "attempted" | "skipped_malformed_identifier";
27+
reason?: string;
28+
repoFullName?: string;
29+
identifier?: string;
30+
attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error";
31+
reentryOutcome?: "merged" | "disengaged" | "other";
32+
prNumber?: number | null;
33+
reentered?: boolean;
34+
reasons?: string[];
35+
};
36+
37+
export type RunLoopOptions = {
38+
env?: Record<string, string | undefined>;
39+
nowMs?: number;
40+
githubToken?: string;
41+
apiBaseUrl?: string;
42+
sleepFn?: (delayMs: number) => Promise<void>;
43+
openGovernorState?: () => GovernorState;
44+
initEventLedger?: () => EventLedger;
45+
initGovernorLedger?: () => GovernorLedger;
46+
initPortfolioQueue?: () => PortfolioQueueStore;
47+
initRunStateStore?: () => RunStateStore;
48+
runDiscover?: (args: string[], options?: Record<string, unknown>) => Promise<number>;
49+
runAttempt?: (args: string[], options?: Record<string, unknown>) => Promise<number>;
50+
resolveAmsPolicy?: (repoFullName: string, options?: Record<string, unknown>) => Promise<{ spec: Record<string, unknown>; source: string; warnings: string[] }>;
51+
checkMinerKillSwitch?: (input?: { env?: Record<string, string | undefined>; repoPaused?: boolean }) => { scope: "global" | "repo" | "none"; active: boolean };
52+
evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { verdict: { reason: string }; canClaimNext: boolean };
53+
pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number }>;
54+
recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown;
55+
buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { sinceSeq: number | null; lastSeq: number };
56+
attemptLoopReentry?: (candidate: unknown, deps: unknown) => { decision: { reenter: boolean; reasons: string[] }; dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null };
57+
attemptOptions?: Record<string, unknown>;
58+
prDispositionOptions?: PollPrDispositionOptions;
59+
};
60+
61+
export function runLoop(args: string[], options?: RunLoopOptions): Promise<number>;

0 commit comments

Comments
 (0)