@dev-loops/core 1.0.2 → 1.0.3
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/package.json +10 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +417 -37
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* finding-cluster.mjs — deterministic fan-in finding clustering for the
|
|
3
|
+
* gate-review sub-loop. Collapses duplicate findings (the SAME root cause
|
|
4
|
+
* reported by more than one reviewer/angle) into one cluster so the judge's
|
|
5
|
+
* relevance disposition and the fixer's act list both reason about root
|
|
6
|
+
* causes, not raw finding count.
|
|
7
|
+
*
|
|
8
|
+
* Pure and offline: no file reads, no network, no state held across calls.
|
|
9
|
+
* Every function deep-clones before enriching and never mutates a caller's
|
|
10
|
+
* array/objects.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveFindingFile } from "./gate-fanin.mjs";
|
|
14
|
+
|
|
15
|
+
// NUL separates the three key components so a value inside one component
|
|
16
|
+
// (e.g. a recommendation that happens to contain a colon or digits matching
|
|
17
|
+
// another component) can never be misread as spanning a boundary.
|
|
18
|
+
const ROOT_CAUSE_KEY_SEPARATOR = "\u0000";
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value @returns {boolean} */
|
|
21
|
+
function isNonEmptyString(value) {
|
|
22
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The canonical root-cause key for a finding: `(reviewed head, primary
|
|
27
|
+
* location "file:line", normalized remediation target)`. A finding missing
|
|
28
|
+
* ANY of the three components is UNKEYABLE and returns null — it is never
|
|
29
|
+
* grouped with another finding, keyable or not.
|
|
30
|
+
*
|
|
31
|
+
* @param {{file?: unknown, files?: unknown, line?: unknown, recommendation?: unknown}} finding
|
|
32
|
+
* @param {{headSha?: unknown}} [options] — the round's reviewed head; a
|
|
33
|
+
* finding never carries its own head, so this is always caller-supplied.
|
|
34
|
+
* @returns {string|null}
|
|
35
|
+
*/
|
|
36
|
+
export function computeRootCauseKey(finding, { headSha } = {}) {
|
|
37
|
+
if (!finding || typeof finding !== "object") return null;
|
|
38
|
+
if (!isNonEmptyString(headSha)) return null;
|
|
39
|
+
const file = resolveFindingFile(finding);
|
|
40
|
+
if (!isNonEmptyString(file)) return null;
|
|
41
|
+
const line = finding.line;
|
|
42
|
+
if (!Number.isInteger(line) || line < 1) return null;
|
|
43
|
+
const recommendation = finding.recommendation;
|
|
44
|
+
if (!isNonEmptyString(recommendation)) return null;
|
|
45
|
+
|
|
46
|
+
const normalizedRecommendation = recommendation.trim().toLowerCase().replace(/\s+/g, " ");
|
|
47
|
+
return [headSha.trim(), `${file}:${line}`, normalizedRecommendation].join(ROOT_CAUSE_KEY_SEPARATOR);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Group findings by EXACT root-cause key match. Every UNKEYABLE finding
|
|
52
|
+
* (see {@link computeRootCauseKey}) is its own singleton cluster, never
|
|
53
|
+
* grouped with another unkeyable finding. Deterministic: clusters are
|
|
54
|
+
* ordered by `representativeIndex` ascending (first appearance), and a
|
|
55
|
+
* cluster's `memberIndices` are ascending.
|
|
56
|
+
*
|
|
57
|
+
* FAIL-OPEN: a non-array `findings`, a missing/empty/non-string `headSha`,
|
|
58
|
+
* or any internal error returns `{ ok: false, reason, clusters }` where
|
|
59
|
+
* `clusters` is one singleton (`keyable: false`) per ORIGINAL finding in
|
|
60
|
+
* input order — so a caller passes every original finding through to the
|
|
61
|
+
* judge unchanged. Never throws.
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} findings
|
|
64
|
+
* @param {{headSha?: unknown}} [options]
|
|
65
|
+
* @returns {{ ok: true, clusters: Array<{key: string|null, keyable: boolean, memberIndices: number[], representativeIndex: number}> }
|
|
66
|
+
* | { ok: false, reason: string, clusters: Array<{key: null, keyable: false, memberIndices: number[], representativeIndex: number}> }}
|
|
67
|
+
*/
|
|
68
|
+
export function clusterFindings(findings, options = {}) {
|
|
69
|
+
try {
|
|
70
|
+
if (!Array.isArray(findings)) {
|
|
71
|
+
throw new Error("clusterFindings requires findings to be an array");
|
|
72
|
+
}
|
|
73
|
+
if (!isNonEmptyString(options?.headSha)) {
|
|
74
|
+
throw new Error("clusterFindings requires a non-empty options.headSha");
|
|
75
|
+
}
|
|
76
|
+
const headSha = options.headSha;
|
|
77
|
+
// A Map preserves insertion order, and every finding is visited index
|
|
78
|
+
// ascending below, so the FIRST index to create a cluster entry is
|
|
79
|
+
// always that cluster's smallest (representative) member — clusters is
|
|
80
|
+
// therefore already representativeIndex-ascending with no extra sort.
|
|
81
|
+
const byKey = new Map();
|
|
82
|
+
const clusters = [];
|
|
83
|
+
findings.forEach((finding, index) => {
|
|
84
|
+
const key = computeRootCauseKey(finding, { headSha });
|
|
85
|
+
if (key === null) {
|
|
86
|
+
clusters.push({ key: null, keyable: false, memberIndices: [index], representativeIndex: index });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let cluster = byKey.get(key);
|
|
90
|
+
if (!cluster) {
|
|
91
|
+
cluster = { key, keyable: true, memberIndices: [], representativeIndex: index };
|
|
92
|
+
byKey.set(key, cluster);
|
|
93
|
+
clusters.push(cluster);
|
|
94
|
+
}
|
|
95
|
+
cluster.memberIndices.push(index);
|
|
96
|
+
});
|
|
97
|
+
return { ok: true, clusters };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
103
|
+
clusters: list.map((_finding, index) => ({ key: null, keyable: false, memberIndices: [index], representativeIndex: index })),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reconstruct the clusters array from findings a producer already stamped
|
|
110
|
+
* with `clusterId` — the LOSSLESS identity consolidate-fanin.mjs computes
|
|
111
|
+
* (via {@link clusterFindings}) on pre-truncation finding text and stamps
|
|
112
|
+
* onto each ledger finding as `finding.clusterId = cluster.representativeIndex`.
|
|
113
|
+
* A consumer reading the ledger back (e.g. judge-pass.mjs) must group by this
|
|
114
|
+
* stamp rather than re-running {@link clusterFindings} on the ledger's own
|
|
115
|
+
* `recommendation`/`file` text, which the ledger pipeline TRUNCATES after
|
|
116
|
+
* clustering — re-deriving from truncated text can merge findings that only
|
|
117
|
+
* share a long truncated prefix, silently discarding the lossless grouping.
|
|
118
|
+
*
|
|
119
|
+
* Every finding must carry an integer `clusterId` (fails closed otherwise —
|
|
120
|
+
* a caller unsure every finding is stamped should fall back to
|
|
121
|
+
* {@link clusterFindings} instead of calling this). Deterministic: clusters
|
|
122
|
+
* are ordered by `representativeIndex` ascending, and each cluster's
|
|
123
|
+
* `memberIndices` are ascending (findings are visited index-ascending).
|
|
124
|
+
*
|
|
125
|
+
* @param {Array<{clusterId?: unknown}>} findings
|
|
126
|
+
* @returns {Array<{key: null, keyable: true, memberIndices: number[], representativeIndex: number}>}
|
|
127
|
+
*/
|
|
128
|
+
export function clustersFromStampedIds(findings) {
|
|
129
|
+
if (!Array.isArray(findings)) {
|
|
130
|
+
throw new TypeError("clustersFromStampedIds requires findings to be an array");
|
|
131
|
+
}
|
|
132
|
+
const byClusterId = new Map();
|
|
133
|
+
findings.forEach((finding, index) => {
|
|
134
|
+
const clusterId = finding?.clusterId;
|
|
135
|
+
if (!Number.isInteger(clusterId)) {
|
|
136
|
+
throw new TypeError(`clustersFromStampedIds requires every finding to carry an integer clusterId (index ${index} does not)`);
|
|
137
|
+
}
|
|
138
|
+
let cluster = byClusterId.get(clusterId);
|
|
139
|
+
if (!cluster) {
|
|
140
|
+
cluster = { key: null, keyable: true, memberIndices: [], representativeIndex: clusterId };
|
|
141
|
+
byClusterId.set(clusterId, cluster);
|
|
142
|
+
}
|
|
143
|
+
cluster.memberIndices.push(index);
|
|
144
|
+
});
|
|
145
|
+
return [...byClusterId.values()].sort((a, b) => a.representativeIndex - b.representativeIndex);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Project every multi-member cluster's REPRESENTATIVE judge disposition
|
|
150
|
+
* (`judgeDisposition`/`judgeRationale`/`judgeCriterion`/`followUpDraft`) onto
|
|
151
|
+
* every member of that cluster — one judge decision per root cause, applied
|
|
152
|
+
* to every finding reporting it. Singleton clusters are unchanged. Pure:
|
|
153
|
+
* deep-clones before enriching, never mutates `enrichedFindings`.
|
|
154
|
+
*
|
|
155
|
+
* Fails closed (throws) when a cluster is malformed or its
|
|
156
|
+
* `memberIndices`/`representativeIndex` reference an out-of-range position.
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<object>} enrichedFindings
|
|
159
|
+
* @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
|
|
160
|
+
* @returns {Array<object>} a new, deep-cloned array.
|
|
161
|
+
*/
|
|
162
|
+
export function projectClusterDisposition(enrichedFindings, clusters) {
|
|
163
|
+
if (!Array.isArray(enrichedFindings)) {
|
|
164
|
+
throw new TypeError("projectClusterDisposition requires enrichedFindings to be an array");
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(clusters)) {
|
|
167
|
+
throw new TypeError("projectClusterDisposition requires clusters to be an array");
|
|
168
|
+
}
|
|
169
|
+
const result = enrichedFindings.map((finding) => structuredClone(finding));
|
|
170
|
+
for (const cluster of clusters) {
|
|
171
|
+
if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) {
|
|
172
|
+
throw new TypeError("projectClusterDisposition requires every cluster to carry memberIndices[] and a representativeIndex");
|
|
173
|
+
}
|
|
174
|
+
if (cluster.representativeIndex < 0 || cluster.representativeIndex >= result.length) {
|
|
175
|
+
throw new RangeError(`projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is out of range (${result.length} findings)`);
|
|
176
|
+
}
|
|
177
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
178
|
+
if (!Number.isInteger(memberIndex) || memberIndex < 0 || memberIndex >= result.length) {
|
|
179
|
+
throw new RangeError(`projectClusterDisposition: cluster memberIndices references out-of-range index ${memberIndex} (${result.length} findings)`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (cluster.memberIndices.length === 0) {
|
|
183
|
+
throw new RangeError("projectClusterDisposition: a cluster must have at least one member (memberIndices is empty)");
|
|
184
|
+
}
|
|
185
|
+
if (!cluster.memberIndices.includes(cluster.representativeIndex)) {
|
|
186
|
+
throw new RangeError(
|
|
187
|
+
`projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is not one of its own memberIndices [${cluster.memberIndices.join(", ")}]`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (cluster.memberIndices.length <= 1) continue; // singleton: shape validated above, nothing to project.
|
|
191
|
+
const representative = result[cluster.representativeIndex];
|
|
192
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
193
|
+
const target = result[memberIndex];
|
|
194
|
+
target.judgeDisposition = representative.judgeDisposition;
|
|
195
|
+
target.judgeRationale = representative.judgeRationale;
|
|
196
|
+
if (representative.judgeCriterion !== undefined) target.judgeCriterion = representative.judgeCriterion;
|
|
197
|
+
else delete target.judgeCriterion;
|
|
198
|
+
if (representative.followUpDraft !== undefined) target.followUpDraft = representative.followUpDraft;
|
|
199
|
+
else delete target.followUpDraft;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reduce an ACT list to at most one finding per acted cluster — the fixer's
|
|
207
|
+
* one-remediation-per-root-cause input. `allFindings` is the SAME array
|
|
208
|
+
* `clusters` was computed against; each act finding is matched back to its
|
|
209
|
+
* original position in it by reference, then to that position's cluster.
|
|
210
|
+
*
|
|
211
|
+
* A cluster's `representativeIndex` is by construction its smallest member
|
|
212
|
+
* index (see {@link clusterFindings}), so — given `actFindings` preserves
|
|
213
|
+
* the round's original relative order, which every producer in this
|
|
214
|
+
* pipeline does — the first member of a cluster encountered here is always
|
|
215
|
+
* either the representative itself, or, when the representative was not
|
|
216
|
+
* itself act-disposed, the earliest-index act member of that cluster: the
|
|
217
|
+
* exact preference rule this function implements, with no extra bookkeeping.
|
|
218
|
+
*
|
|
219
|
+
* A finding whose cluster cannot be resolved (absent from `allFindings`, or
|
|
220
|
+
* no cluster covers its position) passes through unchanged — fail-open,
|
|
221
|
+
* never silently dropped.
|
|
222
|
+
*
|
|
223
|
+
* @param {Array<object>} actFindings
|
|
224
|
+
* @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
|
|
225
|
+
* @param {Array<object>} allFindings
|
|
226
|
+
* @returns {Array<object>}
|
|
227
|
+
*/
|
|
228
|
+
export function dedupeActListByCluster(actFindings, clusters, allFindings) {
|
|
229
|
+
if (!Array.isArray(actFindings)) {
|
|
230
|
+
throw new TypeError("dedupeActListByCluster requires actFindings to be an array");
|
|
231
|
+
}
|
|
232
|
+
const clusterList = Array.isArray(clusters) ? clusters : [];
|
|
233
|
+
const findingsList = Array.isArray(allFindings) ? allFindings : [];
|
|
234
|
+
|
|
235
|
+
const representativeIndexByPosition = new Map();
|
|
236
|
+
for (const cluster of clusterList) {
|
|
237
|
+
if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) continue;
|
|
238
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
239
|
+
representativeIndexByPosition.set(memberIndex, cluster.representativeIndex);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const seenClusters = new Set();
|
|
244
|
+
const kept = [];
|
|
245
|
+
for (const finding of actFindings) {
|
|
246
|
+
const position = findingsList.indexOf(finding);
|
|
247
|
+
const representativeIndex = position === -1 ? undefined : representativeIndexByPosition.get(position);
|
|
248
|
+
if (representativeIndex === undefined) {
|
|
249
|
+
kept.push(finding); // Fail-open: unresolvable membership always passes through.
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (seenClusters.has(representativeIndex)) continue;
|
|
253
|
+
seenClusters.add(representativeIndex);
|
|
254
|
+
kept.push(finding);
|
|
255
|
+
}
|
|
256
|
+
return kept;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A "clean" verdict is invalid the moment any finding was acted on — an
|
|
261
|
+
* acted finding is, by definition, unresolved work. Throws a clear Error on
|
|
262
|
+
* `overallVerdict === "clean" && actCount > 0`; returns `overallVerdict`
|
|
263
|
+
* unchanged otherwise.
|
|
264
|
+
*
|
|
265
|
+
* @param {unknown} overallVerdict
|
|
266
|
+
* @param {number} actCount — non-negative integer; fails closed otherwise.
|
|
267
|
+
* @returns {unknown} `overallVerdict`, unchanged.
|
|
268
|
+
*/
|
|
269
|
+
export function assertCleanImpliesNoAct(overallVerdict, actCount) {
|
|
270
|
+
if (!Number.isInteger(actCount) || actCount < 0) {
|
|
271
|
+
throw new TypeError("assertCleanImpliesNoAct requires actCount to be a non-negative integer");
|
|
272
|
+
}
|
|
273
|
+
if (overallVerdict === "clean" && actCount > 0) {
|
|
274
|
+
throw new Error("clean verdict is invalid with a nonzero act count: any acted finding prevents clean");
|
|
275
|
+
}
|
|
276
|
+
return overallVerdict;
|
|
277
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
|
|
2
|
+
// fail closed when a fixer push leaves the review threads it claims to have
|
|
3
|
+
// tackled incomplete (missing evidence, uncontained commit, unreplied, or
|
|
4
|
+
// unresolved). This module is a PURE evaluator — every GitHub/git fact
|
|
5
|
+
// (live thread state, commit containment) is injected as data, never fetched
|
|
6
|
+
// here, so the same decision is shared verbatim across every harness.
|
|
7
|
+
|
|
8
|
+
/** Disposition values a handoff entry may carry. Only "tackled" entries are
|
|
9
|
+
* auto-disposed by evaluateFixerDisposition; every other value keeps its
|
|
10
|
+
* existing judgment path untouched (see the boundary rule's scope note). */
|
|
11
|
+
export const FIXER_DISPOSITION_KIND = Object.freeze({
|
|
12
|
+
TACKLED: "tackled",
|
|
13
|
+
DEFERRED: "deferred",
|
|
14
|
+
REJECTED: "rejected",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const FIXER_DISPOSITION_FAILED_STEP = Object.freeze({
|
|
18
|
+
MISSING_FROM_HANDOFF: "missing_from_handoff",
|
|
19
|
+
COMMIT_NOT_CONTAINED: "commit_not_contained",
|
|
20
|
+
REPLY_MISSING: "reply_missing",
|
|
21
|
+
NOT_RESOLVED: "not_resolved",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The only legal next action while disposition is incomplete. Kept as a plain
|
|
25
|
+
// literal (not imported from pr-gate-coordination.mjs) so this module stays a
|
|
26
|
+
// dependency-free leaf; pr-gate-coordination.mjs's own PR_CHECKPOINT_ACTION
|
|
27
|
+
// token for this action is asserted (by test) to equal this same string.
|
|
28
|
+
export const COMPLETE_FIXER_DISPOSITION_ACTION = "complete_fixer_disposition";
|
|
29
|
+
|
|
30
|
+
// Broader than pr-gate-coordination's postDraftForbidden: the failure this
|
|
31
|
+
// boundary closes is the NEXT review/gate round opening over a dirty review
|
|
32
|
+
// surface, so every review-(re)request and gate-dispatch token is forbidden
|
|
33
|
+
// too, not just the draft/merge transition tokens. Literal tokens (not
|
|
34
|
+
// imported) for the same dependency-free-leaf reason as above.
|
|
35
|
+
export const FIXER_DISPOSITION_FORBIDDEN_ACTIONS = Object.freeze([
|
|
36
|
+
"run_draft_gate",
|
|
37
|
+
"reconcile_draft_gate",
|
|
38
|
+
"mark_ready_for_review",
|
|
39
|
+
"request_copilot_review",
|
|
40
|
+
"rerequest_copilot_review",
|
|
41
|
+
"run_pre_approval_gate",
|
|
42
|
+
"await_final_human_approval",
|
|
43
|
+
"declare_merge_ready",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isNonEmptyString(value) {
|
|
47
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate + normalize a raw fixer-disposition handoff. Throws on any
|
|
52
|
+
* structural gap the evaluator must never silently tolerate: a missing
|
|
53
|
+
* headSha, a non-array dispositions field, a missing
|
|
54
|
+
* threadId/fixingCommitSha/disposition on any entry, an unrecognized
|
|
55
|
+
* disposition value, or a duplicate threadId/fingerprint across entries.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} raw
|
|
58
|
+
* @returns {{ headSha: string, dispositions: Array<{ threadId: string, fingerprint: string|null, fixingCommitSha: string, disposition: string, validation: string|null }> }}
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeFixerDispositionHandoff(raw) {
|
|
61
|
+
if (!raw || typeof raw !== "object") {
|
|
62
|
+
throw new Error("Fixer disposition handoff must be an object");
|
|
63
|
+
}
|
|
64
|
+
if (!isNonEmptyString(raw.headSha)) {
|
|
65
|
+
throw new Error("Fixer disposition handoff is missing headSha");
|
|
66
|
+
}
|
|
67
|
+
if (raw.dispositions !== undefined && !Array.isArray(raw.dispositions)) {
|
|
68
|
+
throw new Error("Fixer disposition handoff dispositions must be an array");
|
|
69
|
+
}
|
|
70
|
+
const rawDispositions = raw.dispositions ?? [];
|
|
71
|
+
const seenThreadIds = new Set();
|
|
72
|
+
const seenFingerprints = new Set();
|
|
73
|
+
const dispositions = rawDispositions.map((entry, index) => {
|
|
74
|
+
if (!entry || typeof entry !== "object") {
|
|
75
|
+
throw new Error(`Fixer disposition handoff entry ${index} must be an object`);
|
|
76
|
+
}
|
|
77
|
+
if (!isNonEmptyString(entry.threadId)) {
|
|
78
|
+
throw new Error(`Fixer disposition handoff entry ${index} is missing threadId`);
|
|
79
|
+
}
|
|
80
|
+
const threadId = entry.threadId.trim();
|
|
81
|
+
if (seenThreadIds.has(threadId)) {
|
|
82
|
+
throw new Error(`Fixer disposition handoff has a duplicate threadId: ${threadId}`);
|
|
83
|
+
}
|
|
84
|
+
seenThreadIds.add(threadId);
|
|
85
|
+
if (!isNonEmptyString(entry.fixingCommitSha)) {
|
|
86
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing fixingCommitSha`);
|
|
87
|
+
}
|
|
88
|
+
if (!isNonEmptyString(entry.disposition)) {
|
|
89
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing disposition`);
|
|
90
|
+
}
|
|
91
|
+
const fingerprint = isNonEmptyString(entry.fingerprint) ? entry.fingerprint.trim() : null;
|
|
92
|
+
if (fingerprint !== null) {
|
|
93
|
+
if (seenFingerprints.has(fingerprint)) {
|
|
94
|
+
throw new Error(`Fixer disposition handoff has a duplicate fingerprint: ${fingerprint}`);
|
|
95
|
+
}
|
|
96
|
+
seenFingerprints.add(fingerprint);
|
|
97
|
+
}
|
|
98
|
+
const disposition = entry.disposition.trim().toLowerCase();
|
|
99
|
+
if (!Object.values(FIXER_DISPOSITION_KIND).includes(disposition)) {
|
|
100
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} has an unrecognized disposition: ${disposition}`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
threadId,
|
|
104
|
+
fingerprint,
|
|
105
|
+
fixingCommitSha: entry.fixingCommitSha.trim(),
|
|
106
|
+
disposition,
|
|
107
|
+
validation: isNonEmptyString(entry.validation) ? entry.validation.trim() : null,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
headSha: raw.headSha.trim(),
|
|
112
|
+
dispositions,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatIncompleteReason(incomplete) {
|
|
117
|
+
const parts = incomplete.map((entry) => (
|
|
118
|
+
`thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
|
|
119
|
+
));
|
|
120
|
+
return `Fixer disposition is incomplete for ${incomplete.length} tackled thread(s): ${parts.join("; ")}. `
|
|
121
|
+
+ `The only legal next action is ${COMPLETE_FIXER_DISPOSITION_ACTION}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* PURE evaluator: given a normalized handoff, the live thread state, and an
|
|
126
|
+
* injected commit-containment fact table, decide whether every thread the
|
|
127
|
+
* fixer claims to have tackled is fully disposed (commit contained, replied
|
|
128
|
+
* with that commit's evidence, and resolved). No I/O — every fact the
|
|
129
|
+
* decision needs is a plain argument, which is what keeps this seam shared
|
|
130
|
+
* verbatim across harnesses.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} params
|
|
133
|
+
* @param {{ headSha: string, dispositions: Array<object> }} params.handoff - raw or already-normalized; ALWAYS re-validated via normalizeFixerDispositionHandoff (idempotent on already-normalized input), so a malformed array-bearing handoff can never bypass schema+enum validation just by already carrying an array
|
|
134
|
+
* @param {Array<{ threadId: string, isResolved: boolean, replyBodies?: string[], claimedTackled?: boolean }>} [params.liveThreads]
|
|
135
|
+
* @param {Record<string, boolean>} [params.containment] - fixingCommitSha -> true when the observed PR head contains it
|
|
136
|
+
* @returns {{ ok: boolean, incomplete: Array<{ threadId: string, expectedCommit: string|null, failedStep: string }>, forbiddenActions: string[], nextAction: string|null, reason: string|null }}
|
|
137
|
+
*/
|
|
138
|
+
export function evaluateFixerDisposition({ handoff, liveThreads = [], containment = {} } = {}) {
|
|
139
|
+
const normalizedHandoff = normalizeFixerDispositionHandoff(handoff ?? {});
|
|
140
|
+
const handoffByThreadId = new Map(normalizedHandoff.dispositions.map((entry) => [entry.threadId, entry]));
|
|
141
|
+
const liveByThreadId = new Map(
|
|
142
|
+
(Array.isArray(liveThreads) ? liveThreads : [])
|
|
143
|
+
.filter((entry) => entry && typeof entry.threadId === "string")
|
|
144
|
+
.map((entry) => [entry.threadId, entry]),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// The must-check set: every handoff entry explicitly marked "tackled", plus
|
|
148
|
+
// any live thread independently flagged claimedTackled (e.g. a fixer's own
|
|
149
|
+
// claim the CLI cross-referenced against live GitHub state) that has no
|
|
150
|
+
// handoff counterpart at all — that gap is exactly missing_from_handoff.
|
|
151
|
+
const tackledThreadIds = new Set();
|
|
152
|
+
for (const entry of normalizedHandoff.dispositions) {
|
|
153
|
+
if (entry.disposition === FIXER_DISPOSITION_KIND.TACKLED) {
|
|
154
|
+
tackledThreadIds.add(entry.threadId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const liveThread of liveByThreadId.values()) {
|
|
158
|
+
if (liveThread.claimedTackled === true) {
|
|
159
|
+
tackledThreadIds.add(liveThread.threadId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const incomplete = [];
|
|
164
|
+
for (const threadId of tackledThreadIds) {
|
|
165
|
+
const handoffEntry = handoffByThreadId.get(threadId) ?? null;
|
|
166
|
+
if (!handoffEntry) {
|
|
167
|
+
incomplete.push({ threadId, expectedCommit: null, failedStep: FIXER_DISPOSITION_FAILED_STEP.MISSING_FROM_HANDOFF });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const expectedCommit = handoffEntry.fixingCommitSha;
|
|
171
|
+
// Non-goal enforcement: a SHA alone is never evidence. Only an injected
|
|
172
|
+
// containment[sha] === true (the observed PR head provably contains the
|
|
173
|
+
// commit) can authorize the reply/resolve steps below.
|
|
174
|
+
if (containment?.[expectedCommit] !== true) {
|
|
175
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.COMMIT_NOT_CONTAINED });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const liveThread = liveByThreadId.get(threadId) ?? null;
|
|
179
|
+
const replyBodies = Array.isArray(liveThread?.replyBodies) ? liveThread.replyBodies : [];
|
|
180
|
+
const hasEvidencedReply = replyBodies.some((body) => (
|
|
181
|
+
typeof body === "string" && body.toLowerCase().includes(expectedCommit.toLowerCase())
|
|
182
|
+
));
|
|
183
|
+
if (!hasEvidencedReply) {
|
|
184
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.REPLY_MISSING });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (liveThread?.isResolved !== true) {
|
|
188
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.NOT_RESOLVED });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ok = incomplete.length === 0;
|
|
193
|
+
return {
|
|
194
|
+
ok,
|
|
195
|
+
incomplete,
|
|
196
|
+
forbiddenActions: ok ? [] : [...FIXER_DISPOSITION_FORBIDDEN_ACTIONS],
|
|
197
|
+
nextAction: ok ? null : COMPLETE_FIXER_DISPOSITION_ACTION,
|
|
198
|
+
reason: ok ? null : formatIncompleteReason(incomplete),
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -68,6 +68,51 @@ export function backoffMaxConcurrent(maxConcurrent) {
|
|
|
68
68
|
return Math.max(1, Math.floor(cap / 2));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** 30s/60s/120s same-unit retry schedule (`GATE-EXEC-DISPATCH-RETRY-BACKOFF`). */
|
|
72
|
+
const DISPATCH_RETRY_BACKOFF_SCHEDULE_MS = Object.freeze([30_000, 60_000, 120_000]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True when `errorClass` names a transient provider error (429 rate-limit or
|
|
76
|
+
* any 5xx), the class `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retries — as opposed
|
|
77
|
+
* to a hard 4xx (e.g. `402`), which never is.
|
|
78
|
+
* @param {string|number} errorClass
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isTransientDispatchErrorClass(errorClass) {
|
|
82
|
+
const s = String(errorClass ?? "").trim();
|
|
83
|
+
return s === "429" || /^5\d\d$/.test(s) || s.toLowerCase() === "5xx";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pure retry-decision policy for `GATE-EXEC-DISPATCH-RETRY-BACKOFF`: whether a
|
|
88
|
+
* failed dispatch retries the SAME unit, and when a batch reduction is due.
|
|
89
|
+
*
|
|
90
|
+
* A transient failure (429 / 5xx) always retries — the round is never aborted
|
|
91
|
+
* on a transient error, safe because a retry overwrites the same idempotent
|
|
92
|
+
* per-angle findings artifact path (`GATE-EXEC-COLLECTABLE-DISPATCH`), never
|
|
93
|
+
* minting a second one. `delayMs` follows the 30s/60s/120s schedule (holding
|
|
94
|
+
* at 120s past the 3rd attempt); `reduceConcurrency` flips true starting at
|
|
95
|
+
* the 3rd exhausted attempt (`attempt` index 2), signaling the caller to halve
|
|
96
|
+
* the active batch via `backoffMaxConcurrent` — this function only signals
|
|
97
|
+
* WHEN, the halving itself stays `backoffMaxConcurrent`'s job.
|
|
98
|
+
*
|
|
99
|
+
* A hard 4xx (e.g. `402 Insufficient Balance`) is never transient: it escalates
|
|
100
|
+
* to the supervisor/operator immediately instead of retrying into the same wall.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} attempt — 0-based count of prior failed attempts on this unit
|
|
103
|
+
* @param {string|number} errorClass — e.g. `"429"`, `"500"`, `"5xx"`, `"402"`
|
|
104
|
+
* @returns {{ retry: true, delayMs: number, reduceConcurrency: boolean } | { retry: false, escalate: true }}
|
|
105
|
+
*/
|
|
106
|
+
export function planDispatchRetry(attempt, errorClass) {
|
|
107
|
+
if (!isTransientDispatchErrorClass(errorClass)) {
|
|
108
|
+
return { retry: false, escalate: true };
|
|
109
|
+
}
|
|
110
|
+
const n = Number.isInteger(attempt) && attempt >= 0 ? attempt : 0;
|
|
111
|
+
const lastIndex = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS.length - 1;
|
|
112
|
+
const delayMs = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS[Math.min(n, lastIndex)];
|
|
113
|
+
return { retry: true, delayMs, reduceConcurrency: n >= lastIndex };
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* Reviewer-budget preflight for a gate fan-out.
|
|
73
118
|
*
|