@loopover/miner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOYMENT.md +156 -0
- package/Dockerfile +38 -0
- package/README.md +269 -0
- package/bin/gittensory-miner-mcp.d.ts +64 -0
- package/bin/gittensory-miner-mcp.js +279 -0
- package/bin/gittensory-miner.js +196 -0
- package/docs/coding-agent-driver.md +140 -0
- package/docs/config-precedence.md +96 -0
- package/docs/cross-repo-discovery-phase1.md +55 -0
- package/docs/discovery-index-contract.md +60 -0
- package/docs/discovery-plane-operator-guide.md +101 -0
- package/docs/env-reference.md +30 -0
- package/docs/fleet-run-manifest.md +50 -0
- package/docs/miner-goal-spec.md +76 -0
- package/docs/miner-selfimprove-calibration.md +105 -0
- package/docs/observability.md +60 -0
- package/docs/operations-runbook.md +209 -0
- package/docs/repo-agnostic-capability-audit.md +129 -0
- package/docs/unattended-scheduling.md +107 -0
- package/expected-engine.version +1 -0
- package/lib/ams-policy.d.ts +20 -0
- package/lib/ams-policy.js +68 -0
- package/lib/attempt-cli.d.ts +99 -0
- package/lib/attempt-cli.js +523 -0
- package/lib/attempt-input-builder.d.ts +24 -0
- package/lib/attempt-input-builder.js +91 -0
- package/lib/attempt-log.d.ts +37 -0
- package/lib/attempt-log.js +177 -0
- package/lib/attempt-runner.d.ts +59 -0
- package/lib/attempt-runner.js +164 -0
- package/lib/attempt-worktree.d.ts +31 -0
- package/lib/attempt-worktree.js +94 -0
- package/lib/calibration-cli.d.ts +1 -0
- package/lib/calibration-cli.js +92 -0
- package/lib/calibration-run.d.ts +161 -0
- package/lib/calibration-run.js +232 -0
- package/lib/calibration-types.d.ts +46 -0
- package/lib/calibration-types.js +74 -0
- package/lib/calibration.d.ts +24 -0
- package/lib/calibration.js +99 -0
- package/lib/ci-poller.d.ts +33 -0
- package/lib/ci-poller.js +234 -0
- package/lib/claim-adjudication.d.ts +21 -0
- package/lib/claim-adjudication.js +36 -0
- package/lib/claim-conflict-resolver.d.ts +29 -0
- package/lib/claim-conflict-resolver.js +101 -0
- package/lib/claim-ledger-cli.d.ts +59 -0
- package/lib/claim-ledger-cli.js +320 -0
- package/lib/claim-ledger-expiry.d.ts +20 -0
- package/lib/claim-ledger-expiry.js +41 -0
- package/lib/claim-ledger.d.ts +63 -0
- package/lib/claim-ledger.js +303 -0
- package/lib/cli-error.d.ts +3 -0
- package/lib/cli-error.js +27 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +72 -0
- package/lib/coding-agent-construction.d.ts +17 -0
- package/lib/coding-agent-construction.js +103 -0
- package/lib/coding-agent-house-rules.d.ts +24 -0
- package/lib/coding-agent-house-rules.js +64 -0
- package/lib/coding-task-spec.d.ts +44 -0
- package/lib/coding-task-spec.js +183 -0
- package/lib/config-precedence.d.ts +25 -0
- package/lib/config-precedence.js +38 -0
- package/lib/deny-check.d.ts +11 -0
- package/lib/deny-check.js +76 -0
- package/lib/deny-hook-synthesis.d.ts +93 -0
- package/lib/deny-hook-synthesis.js +399 -0
- package/lib/deny-hooks.d.ts +28 -0
- package/lib/deny-hooks.js +155 -0
- package/lib/deployment-docs-audit.d.ts +41 -0
- package/lib/deployment-docs-audit.js +115 -0
- package/lib/discover-cli.d.ts +94 -0
- package/lib/discover-cli.js +284 -0
- package/lib/discovery-throttle.d.ts +8 -0
- package/lib/discovery-throttle.js +33 -0
- package/lib/env-file-indirection.d.ts +4 -0
- package/lib/env-file-indirection.js +45 -0
- package/lib/event-ledger-cli.d.ts +82 -0
- package/lib/event-ledger-cli.js +266 -0
- package/lib/event-ledger.d.ts +37 -0
- package/lib/event-ledger.js +210 -0
- package/lib/execute-local-write.d.ts +14 -0
- package/lib/execute-local-write.js +50 -0
- package/lib/feasibility-cli.d.ts +25 -0
- package/lib/feasibility-cli.js +80 -0
- package/lib/forge-config.d.ts +17 -0
- package/lib/forge-config.js +37 -0
- package/lib/governor-action-mode.d.ts +26 -0
- package/lib/governor-action-mode.js +47 -0
- package/lib/governor-chokepoint-persisted.d.ts +18 -0
- package/lib/governor-chokepoint-persisted.js +46 -0
- package/lib/governor-chokepoint.d.ts +14 -0
- package/lib/governor-chokepoint.js +52 -0
- package/lib/governor-kill-switch.d.ts +26 -0
- package/lib/governor-kill-switch.js +47 -0
- package/lib/governor-ledger-cli.d.ts +32 -0
- package/lib/governor-ledger-cli.js +155 -0
- package/lib/governor-ledger.d.ts +46 -0
- package/lib/governor-ledger.js +203 -0
- package/lib/governor-open-pr.d.ts +21 -0
- package/lib/governor-open-pr.js +27 -0
- package/lib/governor-pause-cli.d.ts +23 -0
- package/lib/governor-pause-cli.js +171 -0
- package/lib/governor-run-halt.d.ts +37 -0
- package/lib/governor-run-halt.js +58 -0
- package/lib/governor-state.d.ts +63 -0
- package/lib/governor-state.js +363 -0
- package/lib/governor-write-rate-limit.d.ts +30 -0
- package/lib/governor-write-rate-limit.js +64 -0
- package/lib/harness-submission-trigger.d.ts +69 -0
- package/lib/harness-submission-trigger.js +138 -0
- package/lib/http-retry.d.ts +12 -0
- package/lib/http-retry.js +52 -0
- package/lib/laptop-init.d.ts +53 -0
- package/lib/laptop-init.js +332 -0
- package/lib/live-issue-snapshot.d.ts +16 -0
- package/lib/live-issue-snapshot.js +117 -0
- package/lib/local-store.d.ts +18 -0
- package/lib/local-store.js +62 -0
- package/lib/logger.d.ts +58 -0
- package/lib/logger.js +167 -0
- package/lib/loop-cli.d.ts +66 -0
- package/lib/loop-cli.js +540 -0
- package/lib/loop-closure.d.ts +35 -0
- package/lib/loop-closure.js +66 -0
- package/lib/loop-reentry.d.ts +51 -0
- package/lib/loop-reentry.js +124 -0
- package/lib/manage-poll.d.ts +79 -0
- package/lib/manage-poll.js +254 -0
- package/lib/manage-status.d.ts +70 -0
- package/lib/manage-status.js +241 -0
- package/lib/metrics-cli.d.ts +9 -0
- package/lib/metrics-cli.js +50 -0
- package/lib/migrate-cli.d.ts +24 -0
- package/lib/migrate-cli.js +111 -0
- package/lib/miner-goal-spec.d.ts +12 -0
- package/lib/miner-goal-spec.js +67 -0
- package/lib/opportunity-fanout.d.ts +86 -0
- package/lib/opportunity-fanout.js +597 -0
- package/lib/opportunity-ranker.d.ts +36 -0
- package/lib/opportunity-ranker.js +118 -0
- package/lib/orb-export.d.ts +54 -0
- package/lib/orb-export.js +206 -0
- package/lib/plan-store-cli.d.ts +37 -0
- package/lib/plan-store-cli.js +151 -0
- package/lib/plan-store.d.ts +51 -0
- package/lib/plan-store.js +231 -0
- package/lib/policy-doc-cache.d.ts +25 -0
- package/lib/policy-doc-cache.js +79 -0
- package/lib/policy-verdict-cache.d.ts +35 -0
- package/lib/policy-verdict-cache.js +106 -0
- package/lib/portfolio-dashboard.d.ts +30 -0
- package/lib/portfolio-dashboard.js +105 -0
- package/lib/portfolio-discovery.d.ts +29 -0
- package/lib/portfolio-discovery.js +100 -0
- package/lib/portfolio-queue-cli.d.ts +87 -0
- package/lib/portfolio-queue-cli.js +509 -0
- package/lib/portfolio-queue-expiry.d.ts +20 -0
- package/lib/portfolio-queue-expiry.js +51 -0
- package/lib/portfolio-queue-manager.d.ts +50 -0
- package/lib/portfolio-queue-manager.js +134 -0
- package/lib/portfolio-queue.d.ts +62 -0
- package/lib/portfolio-queue.js +351 -0
- package/lib/pr-disposition-poller.d.ts +26 -0
- package/lib/pr-disposition-poller.js +163 -0
- package/lib/pr-number-parse.d.ts +4 -0
- package/lib/pr-number-parse.js +23 -0
- package/lib/pr-outcome.d.ts +41 -0
- package/lib/pr-outcome.js +90 -0
- package/lib/prediction-ledger.d.ts +47 -0
- package/lib/prediction-ledger.js +222 -0
- package/lib/pretooluse-hook.d.ts +31 -0
- package/lib/pretooluse-hook.js +93 -0
- package/lib/process-lifecycle.d.ts +31 -0
- package/lib/process-lifecycle.js +106 -0
- package/lib/purge-cli.d.ts +44 -0
- package/lib/purge-cli.js +198 -0
- package/lib/rejection-signal.d.ts +6 -0
- package/lib/rejection-signal.js +101 -0
- package/lib/rejection-state-machine.d.ts +37 -0
- package/lib/rejection-state-machine.js +81 -0
- package/lib/rejection-templates.d.ts +12 -0
- package/lib/rejection-templates.js +71 -0
- package/lib/replay-objective-anchor.d.ts +79 -0
- package/lib/replay-objective-anchor.js +179 -0
- package/lib/replay-snapshot.d.ts +41 -0
- package/lib/replay-snapshot.js +288 -0
- package/lib/replay-task-generation.d.ts +126 -0
- package/lib/replay-task-generation.js +223 -0
- package/lib/repo-clone.d.ts +19 -0
- package/lib/repo-clone.js +102 -0
- package/lib/run-state-cli.d.ts +27 -0
- package/lib/run-state-cli.js +154 -0
- package/lib/run-state.d.ts +37 -0
- package/lib/run-state.js +150 -0
- package/lib/schema-version.d.ts +17 -0
- package/lib/schema-version.js +71 -0
- package/lib/self-review-context.d.ts +28 -0
- package/lib/self-review-context.js +339 -0
- package/lib/slop-assessment.d.ts +3 -0
- package/lib/slop-assessment.js +20 -0
- package/lib/stack-detection.d.ts +41 -0
- package/lib/stack-detection.js +248 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +441 -0
- package/lib/store-maintenance.d.ts +31 -0
- package/lib/store-maintenance.js +183 -0
- package/lib/submission-freshness-check.d.ts +32 -0
- package/lib/submission-freshness-check.js +93 -0
- package/lib/update-check.d.ts +36 -0
- package/lib/update-check.js +161 -0
- package/lib/version.d.ts +3 -0
- package/lib/version.js +10 -0
- package/lib/worktree-allocator.d.ts +39 -0
- package/lib/worktree-allocator.js +262 -0
- package/package.json +50 -0
- package/schema/miner-goal-spec.schema.json +111 -0
package/lib/loop-cli.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// The autonomous supervising loop (#5135, Wave 3.5): the missing daemon/watch layer over the one-shot
|
|
2
|
+
// `discover`/`attempt` subcommands. Every existing piece it composes -- runDiscover, runAttempt,
|
|
3
|
+
// evaluateRunLoopBoundaryGate, attemptLoopReentry, buildLoopClosureSummary, governor-state.js -- already
|
|
4
|
+
// existed; this is the first caller that actually chains them into a real repeat-until-halted run.
|
|
5
|
+
//
|
|
6
|
+
// STRUCTURE (one cycle): kill-switch check -> pause-flag check (#4851, governor-state.js's persisted
|
|
7
|
+
// paused/reason/pausedAt) -> real-per-repo-policy-aware run-loop boundary gate (before claiming) -> real
|
|
8
|
+
// runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll
|
|
9
|
+
// (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry
|
|
10
|
+
// decision. `attemptLoopReentry`'s own dequeue is the
|
|
11
|
+
// AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next
|
|
12
|
+
// candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which
|
|
13
|
+
// would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never-
|
|
14
|
+
// attempted row). A manual dequeueNext() is used only to prime the very first cycle (no prior outcome exists
|
|
15
|
+
// yet to reenter from) and to refill after an empty queue.
|
|
16
|
+
//
|
|
17
|
+
// REAL, NOT FABRICATED: this loop is the first production caller of governor-state.js's `saveCapUsage`
|
|
18
|
+
// (turnsTaken from runMinerAttempt's own real `loopResult.totalTurnsUsed`, elapsedMs from real wall-clock
|
|
19
|
+
// measurement) and of a genuine per-identifier convergence history (attempts/consecutiveFailures/reenqueues
|
|
20
|
+
// tracked in this process's own memory across its own cycles) -- both were previously honest zero/placeholder
|
|
21
|
+
// literals (see attempt-input-builder.js's own header) because a ONE-SHOT `attempt` CLI invocation has no
|
|
22
|
+
// cross-call history to draw on. A long-running loop genuinely does.
|
|
23
|
+
//
|
|
24
|
+
// DOCUMENTED GAP: convergence/cap-usage history is IN-MEMORY, scoped to this loop process's own lifetime (cap
|
|
25
|
+
// usage itself persists across restarts via governor-state.js; per-identifier convergence counters do not --
|
|
26
|
+
// a durable version needs attempt-log.js to grow a repo+issue index, the same separate schema change
|
|
27
|
+
// attempt-input-builder.js's header already flags as out of scope here).
|
|
28
|
+
|
|
29
|
+
import { checkMinerKillSwitch } from "./governor-kill-switch.js";
|
|
30
|
+
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
|
|
31
|
+
import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js";
|
|
32
|
+
import { openGovernorState } from "./governor-state.js";
|
|
33
|
+
import { initGovernorLedger } from "./governor-ledger.js";
|
|
34
|
+
import { initEventLedger } from "./event-ledger.js";
|
|
35
|
+
import { initPortfolioQueueStore } from "./portfolio-queue.js";
|
|
36
|
+
import { initRunStateStore } from "./run-state.js";
|
|
37
|
+
import { runDiscover } from "./discover-cli.js";
|
|
38
|
+
import { runAttempt } from "./attempt-cli.js";
|
|
39
|
+
import { resolveAmsPolicy } from "./ams-policy.js";
|
|
40
|
+
import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js";
|
|
41
|
+
import { pollCheckRuns } from "./ci-poller.js";
|
|
42
|
+
import { recordPrOutcomeSnapshot } from "./pr-outcome.js";
|
|
43
|
+
import { buildLoopClosureSummary } from "./loop-closure.js";
|
|
44
|
+
import { attemptLoopReentry } from "./loop-reentry.js";
|
|
45
|
+
import { parsePrNumberFromExecResult } from "./pr-number-parse.js";
|
|
46
|
+
import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine";
|
|
47
|
+
|
|
48
|
+
const LOOP_USAGE =
|
|
49
|
+
"Usage: gittensory-miner loop <owner/repo> [<owner/repo>...] | --search <query> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]";
|
|
50
|
+
const DEFAULT_CYCLE_DELAY_MS = 60_000;
|
|
51
|
+
const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/;
|
|
52
|
+
|
|
53
|
+
function parseRepoTarget(value) {
|
|
54
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
55
|
+
const [owner, repo, extra] = trimmed.split("/");
|
|
56
|
+
if (!owner || !repo || extra !== undefined) return null;
|
|
57
|
+
return `${owner}/${repo}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeOptionalPositiveInt(value, label) {
|
|
61
|
+
const parsedValue = Number(value);
|
|
62
|
+
if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) {
|
|
63
|
+
throw new Error(`${label} must be a non-negative integer: ${value}`);
|
|
64
|
+
}
|
|
65
|
+
return parsedValue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseLoopArgs(args) {
|
|
69
|
+
const options = {
|
|
70
|
+
json: false,
|
|
71
|
+
minerLogin: null,
|
|
72
|
+
base: "main",
|
|
73
|
+
live: false,
|
|
74
|
+
dryRun: false,
|
|
75
|
+
search: null,
|
|
76
|
+
maxCycles: undefined,
|
|
77
|
+
cycleDelayMs: DEFAULT_CYCLE_DELAY_MS,
|
|
78
|
+
};
|
|
79
|
+
const targets = [];
|
|
80
|
+
|
|
81
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
82
|
+
const token = args[index];
|
|
83
|
+
if (token === "--json") {
|
|
84
|
+
options.json = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (token === "--live") {
|
|
88
|
+
options.live = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits
|
|
92
|
+
// BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes.
|
|
93
|
+
if (token === "--dry-run") {
|
|
94
|
+
options.dryRun = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token === "--search") {
|
|
98
|
+
const value = args[index + 1];
|
|
99
|
+
if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
|
|
100
|
+
options.search = value;
|
|
101
|
+
index += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (token === "--miner-login") {
|
|
105
|
+
const value = args[index + 1];
|
|
106
|
+
if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
|
|
107
|
+
options.minerLogin = value;
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (token === "--base") {
|
|
112
|
+
const value = args[index + 1];
|
|
113
|
+
if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
|
|
114
|
+
options.base = value;
|
|
115
|
+
index += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (token === "--max-cycles") {
|
|
119
|
+
const value = args[index + 1];
|
|
120
|
+
if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
|
|
121
|
+
try {
|
|
122
|
+
options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
125
|
+
}
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token === "--cycle-delay-ms") {
|
|
130
|
+
const value = args[index + 1];
|
|
131
|
+
if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
|
|
132
|
+
try {
|
|
133
|
+
options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
136
|
+
}
|
|
137
|
+
index += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
|
|
141
|
+
const target = parseRepoTarget(token);
|
|
142
|
+
if (!target) return { error: `Repository must be in owner/repo form: ${token}` };
|
|
143
|
+
targets.push(target);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.search === null && targets.length === 0) return { error: LOOP_USAGE };
|
|
147
|
+
if (options.search !== null && targets.length > 0) return { error: "Pass either repository targets or --search, not both." };
|
|
148
|
+
if (!options.minerLogin) return { error: `--miner-login is required. ${LOOP_USAGE}` };
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
targets,
|
|
152
|
+
search: options.search,
|
|
153
|
+
minerLogin: options.minerLogin,
|
|
154
|
+
base: options.base,
|
|
155
|
+
live: options.live,
|
|
156
|
+
dryRun: options.dryRun,
|
|
157
|
+
maxCycles: options.maxCycles,
|
|
158
|
+
cycleDelayMs: options.cycleDelayMs,
|
|
159
|
+
json: options.json,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function discoverArgv(parsed) {
|
|
164
|
+
return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseIssueNumberFromIdentifier(identifier) {
|
|
168
|
+
const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null;
|
|
169
|
+
return match ? Number(match[1]) : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function convergenceKey(repoFullName, identifier) {
|
|
173
|
+
return `${repoFullName}:${identifier}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function zeroConvergence() {
|
|
177
|
+
return { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips,
|
|
182
|
+
* the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined,
|
|
183
|
+
* or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} args
|
|
186
|
+
* @param {{
|
|
187
|
+
* env?: Record<string, string | undefined>,
|
|
188
|
+
* nowMs?: number,
|
|
189
|
+
* githubToken?: string,
|
|
190
|
+
* apiBaseUrl?: string,
|
|
191
|
+
* sleepFn?: (delayMs: number) => Promise<void>,
|
|
192
|
+
* openGovernorState?: typeof openGovernorState,
|
|
193
|
+
* initEventLedger?: typeof initEventLedger,
|
|
194
|
+
* initGovernorLedger?: typeof initGovernorLedger,
|
|
195
|
+
* initPortfolioQueue?: () => import("./portfolio-queue.js").PortfolioQueueStore,
|
|
196
|
+
* initRunStateStore?: typeof initRunStateStore,
|
|
197
|
+
* runDiscover?: typeof runDiscover,
|
|
198
|
+
* runAttempt?: typeof runAttempt,
|
|
199
|
+
* resolveAmsPolicy?: typeof resolveAmsPolicy,
|
|
200
|
+
* checkMinerKillSwitch?: typeof checkMinerKillSwitch,
|
|
201
|
+
* evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate,
|
|
202
|
+
* pollPrDisposition?: typeof pollPrDisposition,
|
|
203
|
+
* pollCheckRuns?: typeof pollCheckRuns,
|
|
204
|
+
* recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot,
|
|
205
|
+
* buildLoopClosureSummary?: typeof buildLoopClosureSummary,
|
|
206
|
+
* attemptLoopReentry?: typeof attemptLoopReentry,
|
|
207
|
+
* attemptOptions?: Record<string, unknown>,
|
|
208
|
+
* prDispositionOptions?: Record<string, unknown>,
|
|
209
|
+
* ciPollOptions?: Record<string, unknown>,
|
|
210
|
+
* }} [options]
|
|
211
|
+
* @returns {Promise<number>}
|
|
212
|
+
*/
|
|
213
|
+
export async function runLoop(args, options = {}) {
|
|
214
|
+
const parsed = parseLoopArgs(args);
|
|
215
|
+
if ("error" in parsed) {
|
|
216
|
+
return reportCliFailure(argsWantJson(args), parsed.error);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const env = options.env ?? process.env;
|
|
220
|
+
const sleepFn = options.sleepFn ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
|
|
221
|
+
const nowMsFn = () => options.nowMs ?? Date.now();
|
|
222
|
+
const sessionStartMs = nowMsFn();
|
|
223
|
+
|
|
224
|
+
// #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other
|
|
225
|
+
// store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just
|
|
226
|
+
// "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL
|
|
227
|
+
// portfolio queue even before any attempt happens, so a faithful dry run cannot call it either.
|
|
228
|
+
if (parsed.dryRun) {
|
|
229
|
+
const dryRunResult = {
|
|
230
|
+
outcome: "dry_run",
|
|
231
|
+
targets: parsed.targets,
|
|
232
|
+
search: parsed.search,
|
|
233
|
+
minerLogin: parsed.minerLogin,
|
|
234
|
+
base: parsed.base,
|
|
235
|
+
live: parsed.live,
|
|
236
|
+
maxCycles: parsed.maxCycles ?? null,
|
|
237
|
+
};
|
|
238
|
+
if (parsed.json) {
|
|
239
|
+
console.log(JSON.stringify(dryRunResult, null, 2));
|
|
240
|
+
} else {
|
|
241
|
+
const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", ");
|
|
242
|
+
console.log(
|
|
243
|
+
`DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let governorState;
|
|
250
|
+
try {
|
|
251
|
+
governorState = (options.openGovernorState ?? openGovernorState)();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return reportCliFailure(
|
|
254
|
+
parsed.json,
|
|
255
|
+
`Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`,
|
|
256
|
+
3,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const eventLedger = (options.initEventLedger ?? initEventLedger)();
|
|
261
|
+
const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)();
|
|
262
|
+
const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
|
|
263
|
+
const runState = (options.initRunStateStore ?? initRunStateStore)();
|
|
264
|
+
|
|
265
|
+
const runDiscoverFn = options.runDiscover ?? runDiscover;
|
|
266
|
+
const runAttemptFn = options.runAttempt ?? runAttempt;
|
|
267
|
+
const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy;
|
|
268
|
+
const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
|
|
269
|
+
const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate;
|
|
270
|
+
const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition;
|
|
271
|
+
const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns;
|
|
272
|
+
const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot;
|
|
273
|
+
const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary;
|
|
274
|
+
const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry;
|
|
275
|
+
|
|
276
|
+
// Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its
|
|
277
|
+
// recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is
|
|
278
|
+
// where `process.env.GITHUB_TOKEN` gets read, then threaded down explicitly to every real GitHub caller).
|
|
279
|
+
// pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO
|
|
280
|
+
// such fallback -- an unresolved githubToken here would silently poll unauthenticated.
|
|
281
|
+
const githubToken = options.githubToken ?? env.GITHUB_TOKEN ?? "";
|
|
282
|
+
|
|
283
|
+
async function runDiscoveryOnce() {
|
|
284
|
+
await runDiscoverFn(discoverArgv(parsed), {
|
|
285
|
+
initPortfolioQueue: () => portfolioQueue,
|
|
286
|
+
githubToken,
|
|
287
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
288
|
+
nowMs: nowMsFn(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let usage = governorState.loadCapUsage();
|
|
293
|
+
const convergenceHistory = new Map();
|
|
294
|
+
const cycles = [];
|
|
295
|
+
let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0;
|
|
296
|
+
let haltReason = null;
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
// Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill
|
|
300
|
+
// switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The
|
|
301
|
+
// pause flag is real, persisted, operator/governor-writable state on governorState (toggled via
|
|
302
|
+
// `gittensory-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being
|
|
303
|
+
// re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own
|
|
304
|
+
// cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off.
|
|
305
|
+
const initialKillSwitch = checkKillSwitchFn({ env });
|
|
306
|
+
const initialPauseState = governorState.loadPauseState();
|
|
307
|
+
let claimed = null;
|
|
308
|
+
if (initialKillSwitch.active) {
|
|
309
|
+
haltReason = `kill_switch_${initialKillSwitch.scope}`;
|
|
310
|
+
cycles.push({ cycle: 1, outcome: "halted", reason: haltReason });
|
|
311
|
+
} else if (initialPauseState.paused) {
|
|
312
|
+
haltReason = "paused";
|
|
313
|
+
cycles.push({ cycle: 1, outcome: "halted", reason: haltReason });
|
|
314
|
+
} else {
|
|
315
|
+
await runDiscoveryOnce();
|
|
316
|
+
claimed = portfolioQueue.dequeueNext();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let cycleIndex = haltReason !== null ? 1 : 0;
|
|
320
|
+
while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) {
|
|
321
|
+
cycleIndex += 1;
|
|
322
|
+
|
|
323
|
+
const killSwitch = checkKillSwitchFn({ env });
|
|
324
|
+
if (killSwitch.active) {
|
|
325
|
+
haltReason = `kill_switch_${killSwitch.scope}`;
|
|
326
|
+
cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason });
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const pauseState = governorState.loadPauseState();
|
|
331
|
+
if (pauseState.paused) {
|
|
332
|
+
haltReason = "paused";
|
|
333
|
+
cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason });
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!claimed) {
|
|
338
|
+
cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" });
|
|
339
|
+
await sleepFn(parsed.cycleDelayMs);
|
|
340
|
+
await runDiscoveryOnce();
|
|
341
|
+
claimed = portfolioQueue.dequeueNext();
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier);
|
|
346
|
+
if (issueNumber === null) {
|
|
347
|
+
// Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than
|
|
348
|
+
// crash the whole run: this exact item can never be attempted, so it will never resolve on retry.
|
|
349
|
+
portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
|
|
350
|
+
cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier });
|
|
351
|
+
claimed = portfolioQueue.dequeueNext();
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const key = convergenceKey(claimed.repoFullName, claimed.identifier);
|
|
356
|
+
const amsPolicy = await resolveAmsPolicyFn(claimed.repoFullName, { env });
|
|
357
|
+
const convergenceInput = convergenceHistory.get(key) ?? zeroConvergence();
|
|
358
|
+
|
|
359
|
+
const boundary = evaluateBoundaryGateFn(
|
|
360
|
+
{
|
|
361
|
+
runHalted: false,
|
|
362
|
+
usage,
|
|
363
|
+
limits: amsPolicy.spec.capLimits ?? DEFAULT_AMS_POLICY_SPEC.capLimits,
|
|
364
|
+
convergence: convergenceInput,
|
|
365
|
+
convergenceThresholds: amsPolicy.spec.convergenceThresholds ?? DEFAULT_AMS_POLICY_SPEC.convergenceThresholds,
|
|
366
|
+
inFlightItem: { repoFullName: claimed.repoFullName, identifier: claimed.identifier },
|
|
367
|
+
// Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge
|
|
368
|
+
// hosts can share an in-flight item with the same repo name+identifier.
|
|
369
|
+
markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimed.apiBaseUrl),
|
|
370
|
+
},
|
|
371
|
+
{ append: (event) => governorLedger.appendGovernorEvent(event) },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
if (!boundary.canClaimNext) {
|
|
375
|
+
haltReason = `boundary_${boundary.verdict.reason}`;
|
|
376
|
+
cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimed.repoFullName, identifier: claimed.identifier });
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
convergenceInput.attempts += 1;
|
|
381
|
+
convergenceHistory.set(key, convergenceInput);
|
|
382
|
+
|
|
383
|
+
const cycleStartMs = nowMsFn();
|
|
384
|
+
let lastResult = null;
|
|
385
|
+
const attemptArgv = [
|
|
386
|
+
claimed.repoFullName,
|
|
387
|
+
String(issueNumber),
|
|
388
|
+
"--miner-login",
|
|
389
|
+
parsed.minerLogin,
|
|
390
|
+
"--base",
|
|
391
|
+
parsed.base,
|
|
392
|
+
...(parsed.live ? ["--live"] : []),
|
|
393
|
+
];
|
|
394
|
+
await runAttemptFn(attemptArgv, {
|
|
395
|
+
...(options.attemptOptions ?? {}),
|
|
396
|
+
env,
|
|
397
|
+
onResult: (result) => {
|
|
398
|
+
lastResult = result;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
const cycleElapsedMs = nowMsFn() - cycleStartMs;
|
|
402
|
+
|
|
403
|
+
usage = {
|
|
404
|
+
// Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through
|
|
405
|
+
// runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli)
|
|
406
|
+
// report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a
|
|
407
|
+
// fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend.
|
|
408
|
+
budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0),
|
|
409
|
+
turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0),
|
|
410
|
+
elapsedMs: usage.elapsedMs + cycleElapsedMs,
|
|
411
|
+
};
|
|
412
|
+
governorState.saveCapUsage(usage);
|
|
413
|
+
|
|
414
|
+
const attemptOutcome = lastResult?.outcome ?? "attempt_error";
|
|
415
|
+
const submitted = attemptOutcome === "attempt_submitted";
|
|
416
|
+
// A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches
|
|
417
|
+
// rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/
|
|
418
|
+
// abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a
|
|
419
|
+
// different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence
|
|
420
|
+
// (reenqueues threshold) rather than silently retried forever.
|
|
421
|
+
const permanentBlock = attemptOutcome === "blocked_rejection_signaled";
|
|
422
|
+
|
|
423
|
+
if (submitted) {
|
|
424
|
+
portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
|
|
425
|
+
convergenceInput.reachedDone = true;
|
|
426
|
+
convergenceInput.consecutiveFailures = 0;
|
|
427
|
+
} else if (permanentBlock) {
|
|
428
|
+
portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
|
|
429
|
+
convergenceInput.consecutiveFailures += 1;
|
|
430
|
+
} else {
|
|
431
|
+
portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
|
|
432
|
+
convergenceInput.consecutiveFailures += 1;
|
|
433
|
+
convergenceInput.reenqueues += 1;
|
|
434
|
+
}
|
|
435
|
+
convergenceHistory.set(key, convergenceInput);
|
|
436
|
+
|
|
437
|
+
let reentryOutcome = "other";
|
|
438
|
+
let prNumber = null;
|
|
439
|
+
let prDisposition = null;
|
|
440
|
+
let ciConclusion = null;
|
|
441
|
+
if (submitted) {
|
|
442
|
+
prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName);
|
|
443
|
+
if (prNumber !== null) {
|
|
444
|
+
// Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted
|
|
445
|
+
// PR's check-run state is captured even while it's still open, not just at its eventual merge/close.
|
|
446
|
+
// ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the
|
|
447
|
+
// authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly
|
|
448
|
+
// from GitHub's own PR state rather than a server-internal endpoint (#5450).
|
|
449
|
+
const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, {
|
|
450
|
+
githubToken,
|
|
451
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
452
|
+
...(options.ciPollOptions ?? {}),
|
|
453
|
+
});
|
|
454
|
+
ciConclusion = ciStatus.conclusion;
|
|
455
|
+
eventLedger.appendEvent({
|
|
456
|
+
type: "ci_status_observed",
|
|
457
|
+
repoFullName: claimed.repoFullName,
|
|
458
|
+
payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" },
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, {
|
|
462
|
+
githubToken,
|
|
463
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
464
|
+
...(options.prDispositionOptions ?? {}),
|
|
465
|
+
});
|
|
466
|
+
if (prDisposition.state === "closed") {
|
|
467
|
+
recordPrOutcomeSnapshotFn(
|
|
468
|
+
{
|
|
469
|
+
repoFullName: claimed.repoFullName,
|
|
470
|
+
prNumber,
|
|
471
|
+
decision: prDisposition.merged ? "merged" : "closed",
|
|
472
|
+
closedAt: prDisposition.closedAt,
|
|
473
|
+
},
|
|
474
|
+
{ eventLedger },
|
|
475
|
+
);
|
|
476
|
+
reentryOutcome = classifyPrDisposition(prDisposition);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const loopSummary = buildLoopClosureSummaryFn(
|
|
482
|
+
{ eventLedger, portfolioQueue, runState },
|
|
483
|
+
{ sinceSeq, repoFullName: claimed.repoFullName },
|
|
484
|
+
);
|
|
485
|
+
sinceSeq = loopSummary.lastSeq;
|
|
486
|
+
|
|
487
|
+
const reentry = attemptLoopReentryFn(
|
|
488
|
+
{ killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome },
|
|
489
|
+
{ eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary },
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
cycles.push({
|
|
493
|
+
cycle: cycleIndex,
|
|
494
|
+
outcome: "attempted",
|
|
495
|
+
repoFullName: claimed.repoFullName,
|
|
496
|
+
identifier: claimed.identifier,
|
|
497
|
+
attemptOutcome,
|
|
498
|
+
reentryOutcome,
|
|
499
|
+
prNumber,
|
|
500
|
+
ciConclusion,
|
|
501
|
+
reentered: reentry.decision.reenter,
|
|
502
|
+
reasons: reentry.decision.reasons,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
if (!reentry.decision.reenter) {
|
|
506
|
+
haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (reentry.dequeued) {
|
|
511
|
+
claimed = reentry.dequeued;
|
|
512
|
+
await sleepFn(parsed.cycleDelayMs);
|
|
513
|
+
} else {
|
|
514
|
+
await sleepFn(parsed.cycleDelayMs);
|
|
515
|
+
await runDiscoveryOnce();
|
|
516
|
+
claimed = portfolioQueue.dequeueNext();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (haltReason === null && parsed.maxCycles !== undefined) {
|
|
521
|
+
haltReason = "max_cycles_reached";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const summary = { haltReason, cyclesRun: cycles.length, cycles };
|
|
525
|
+
if (parsed.json) {
|
|
526
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
527
|
+
} else {
|
|
528
|
+
console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason ?? "unknown"}.`);
|
|
529
|
+
}
|
|
530
|
+
return 0;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return reportCliFailure(parsed.json, describeCliError(error));
|
|
533
|
+
} finally {
|
|
534
|
+
governorState.close();
|
|
535
|
+
eventLedger.close();
|
|
536
|
+
governorLedger.close();
|
|
537
|
+
portfolioQueue.close();
|
|
538
|
+
runState.close();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface LoopClosureEventLedger {
|
|
2
|
+
readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ seq?: number; type?: unknown; repoFullName?: string | null }>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface LoopClosurePortfolioQueue {
|
|
6
|
+
listQueue(repoFullName: string | null): Array<{ status?: unknown }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LoopClosureRunState {
|
|
10
|
+
getRunState(repoFullName: string): string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LoopClosureSources {
|
|
14
|
+
eventLedger: LoopClosureEventLedger;
|
|
15
|
+
portfolioQueue: LoopClosurePortfolioQueue;
|
|
16
|
+
runState?: LoopClosureRunState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoopClosureOptions {
|
|
20
|
+
/** Event-ledger seq at the END of the prior cycle; events with a strictly greater seq are "this cycle". */
|
|
21
|
+
sinceSeq?: number;
|
|
22
|
+
/** Scope the summary to a single repo (its events and queue entries) when set. */
|
|
23
|
+
repoFullName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LoopClosureSummary {
|
|
27
|
+
sinceSeq: number | null;
|
|
28
|
+
/** Highest event seq observed this cycle (>= sinceSeq); the boundary a caller passes as the next cycle's sinceSeq. */
|
|
29
|
+
lastSeq: number;
|
|
30
|
+
events: { total: number; byType: Record<string, number> };
|
|
31
|
+
queue: { total: number; byStatus: Record<string, number> };
|
|
32
|
+
runState: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildLoopClosureSummary(sources: LoopClosureSources, options?: LoopClosureOptions): LoopClosureSummary;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Loop-closure summary builder (pure, read-only) — #4282, Wave 2 tracker #2353 (miner-manage phase).
|
|
2
|
+
//
|
|
3
|
+
// A pure, read-only aggregator in the spirit of manage-status.js's collectManageStatus: read across the local-state
|
|
4
|
+
// primitives (event ledger, portfolio queue, run-state) and summarize what happened in a completed
|
|
5
|
+
// discover→plan→prepare→manage cycle BEFORE the miner loop considers re-entering (idle → discovering again). It
|
|
6
|
+
// never calls GitHub, never writes a local store, and never decides whether to re-enter or performs the re-entry
|
|
7
|
+
// itself — it only builds the summary a future caller reads before making that call.
|
|
8
|
+
//
|
|
9
|
+
// Cycle boundary is CALLER-SUPPLIED (deliberately, per the issue): `options.sinceSeq` is the event-ledger seq at the
|
|
10
|
+
// END of the prior cycle, so events with a STRICTLY greater seq are "this cycle" — reusing event-ledger.js's own
|
|
11
|
+
// `readEvents({ since })` cursor rather than inventing a new persisted cycle-boundary marker. The ledger stores an
|
|
12
|
+
// OPEN type vocabulary (only the phase writers define concrete types), so events are tallied GENERICALLY by `type`;
|
|
13
|
+
// new phase event types (plans built, PRs prepared/opened, outcomes recorded — landing via sibling issues) surface
|
|
14
|
+
// in the tally automatically without a hardcoded list here.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a read-only loop-closure summary from local-state sources. Pure: reads `sources` + `options` and returns a
|
|
18
|
+
* structured summary, mutating nothing.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ eventLedger: { readEvents: Function }, portfolioQueue: { listQueue: Function }, runState?: { getRunState: Function } }} sources
|
|
21
|
+
* @param {{ sinceSeq?: number, repoFullName?: string }} [options]
|
|
22
|
+
* @returns {{ sinceSeq: number|null, lastSeq: number, events: { total: number, byType: Record<string, number> }, queue: { total: number, byStatus: Record<string, number> }, runState: string|null }}
|
|
23
|
+
*/
|
|
24
|
+
export function buildLoopClosureSummary(sources, options = {}) {
|
|
25
|
+
const eventLedger = sources?.eventLedger;
|
|
26
|
+
const portfolioQueue = sources?.portfolioQueue;
|
|
27
|
+
const runState = sources?.runState;
|
|
28
|
+
if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger");
|
|
29
|
+
if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") throw new Error("invalid_portfolio_queue");
|
|
30
|
+
|
|
31
|
+
const repoFullName = typeof options.repoFullName === "string" && options.repoFullName.length > 0 ? options.repoFullName : null;
|
|
32
|
+
const sinceSeq = Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : null;
|
|
33
|
+
|
|
34
|
+
// Bound "this cycle" to events after the prior cycle's ending seq; event-ledger applies the `since`/repo filter.
|
|
35
|
+
const filter = {};
|
|
36
|
+
if (repoFullName !== null) filter.repoFullName = repoFullName;
|
|
37
|
+
if (sinceSeq !== null) filter.since = sinceSeq;
|
|
38
|
+
const events = eventLedger.readEvents(filter);
|
|
39
|
+
|
|
40
|
+
const byType = {};
|
|
41
|
+
let lastSeq = sinceSeq ?? 0;
|
|
42
|
+
for (const event of events) {
|
|
43
|
+
const type = typeof event?.type === "string" && event.type.length > 0 ? event.type : "unknown";
|
|
44
|
+
byType[type] = (byType[type] ?? 0) + 1;
|
|
45
|
+
if (Number.isInteger(event?.seq) && event.seq > lastSeq) lastSeq = event.seq;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const byStatus = {};
|
|
49
|
+
const queueEntries = portfolioQueue.listQueue(repoFullName);
|
|
50
|
+
for (const entry of queueEntries) {
|
|
51
|
+
const status = typeof entry?.status === "string" && entry.status.length > 0 ? entry.status : "unknown";
|
|
52
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const currentRunState = runState && typeof runState.getRunState === "function" && repoFullName !== null
|
|
56
|
+
? runState.getRunState(repoFullName)
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
sinceSeq,
|
|
61
|
+
lastSeq,
|
|
62
|
+
events: { total: events.length, byType },
|
|
63
|
+
queue: { total: queueEntries.length, byStatus },
|
|
64
|
+
runState: currentRunState ?? null,
|
|
65
|
+
};
|
|
66
|
+
}
|