@dev-loops/core 1.0.2 → 1.0.4-pre.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/package.json +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -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 +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- 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/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-launch-bound.mjs — dev-loop execution-cap fail-fast child-launch
|
|
3
|
+
* bound (epic decided approach): a bounded, deterministic primitive that
|
|
4
|
+
* attempts a child launch AT MOST ONCE, queries the harness-supported-model
|
|
5
|
+
* inventory AT MOST ONCE and ONLY after a failed launch, and always produces
|
|
6
|
+
* a durable blocker record on failure — never a retry, a silent model
|
|
7
|
+
* substitution, or a dispatch without the requested override.
|
|
8
|
+
*
|
|
9
|
+
* Pure and offline: launch/query behavior is fully caller-injected
|
|
10
|
+
* (attemptLaunch/querySupportedModels); this module never imports a runtime
|
|
11
|
+
* harness adapter, reads a file, or performs I/O of its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dev-loop harnesses this bound recognizes; any other value fails closed.
|
|
16
|
+
* This primitive only carries the harness label through the request/blocker
|
|
17
|
+
* — it does not branch on it or depend on per-harness capability data (e.g.
|
|
18
|
+
* HARNESS_DEFAULT_CAPABILITIES), so it stays agnostic across pi, claude, and
|
|
19
|
+
* codex.
|
|
20
|
+
*/
|
|
21
|
+
const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
|
|
22
|
+
|
|
23
|
+
/** @param {unknown} value @returns {boolean} */
|
|
24
|
+
function isNonEmptyString(value) {
|
|
25
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate + normalize the child-launch request. Fails closed (TypeError) on
|
|
30
|
+
* any malformed/empty field, at the trust boundary of this function.
|
|
31
|
+
* @param {object} request
|
|
32
|
+
* @returns {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}}
|
|
33
|
+
*/
|
|
34
|
+
function validateRequest(request) {
|
|
35
|
+
if (!request || typeof request !== "object") {
|
|
36
|
+
throw new TypeError("enforceChildLaunchBound requires a request object");
|
|
37
|
+
}
|
|
38
|
+
const { run, roleOrAngle, model, harness } = request;
|
|
39
|
+
if (!isNonEmptyString(run)) throw new TypeError("request.run must be a non-empty string");
|
|
40
|
+
if (!isNonEmptyString(roleOrAngle)) throw new TypeError("request.roleOrAngle must be a non-empty string");
|
|
41
|
+
if (!isNonEmptyString(model)) throw new TypeError("request.model must be a non-empty string");
|
|
42
|
+
if (!HARNESS_VALUES.includes(harness)) {
|
|
43
|
+
throw new TypeError(`request.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(harness)}`);
|
|
44
|
+
}
|
|
45
|
+
return { run: run.trim(), roleOrAngle: roleOrAngle.trim(), model: model.trim(), harness };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Note: querySupportedModels may return { supported: [...] }, a bare
|
|
49
|
+
// array, or a Set — normalizing all three into one Set here keeps the
|
|
50
|
+
// caller-facing contract flexible without adding a second exported shape.
|
|
51
|
+
/** @param {{supported?: unknown}|unknown[]|Set<string>} result @returns {Set<string>} */
|
|
52
|
+
function toSupportedSet(result) {
|
|
53
|
+
const list = result && typeof result === "object" && !Array.isArray(result) && !(result instanceof Set)
|
|
54
|
+
? result.supported
|
|
55
|
+
: result;
|
|
56
|
+
if (list instanceof Set) return list;
|
|
57
|
+
if (Array.isArray(list)) return new Set(list);
|
|
58
|
+
return new Set();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enforce the bounded, deterministic child-launch fail-fast protocol.
|
|
63
|
+
*
|
|
64
|
+
* Scope: this bounds ONE invocation — one launch attempt plus at most one
|
|
65
|
+
* inventory query for that same call. It holds no state across calls, so
|
|
66
|
+
* cross-call terminality (never re-launching the same `(run, roleOrAngle,
|
|
67
|
+
* model)` after a prior call already returned a blocked verdict) is the
|
|
68
|
+
* caller's/coordinator's contract, not something a persisted token here
|
|
69
|
+
* enforces.
|
|
70
|
+
*
|
|
71
|
+
* Deadline contract: `deadlineMs` is a MEASUREMENT bound, not a per-call
|
|
72
|
+
* timeout. The function times the elapsed wall-clock span of the one launch
|
|
73
|
+
* attempt plus (on failure) the one inventory query, and reports
|
|
74
|
+
* `withinDeadline` fail-closed (`elapsedMs <= deadlineMs`) — it never cancels
|
|
75
|
+
* or races `attemptLaunch`/`querySupportedModels` against the clock. The
|
|
76
|
+
* <=60s guarantee holds only because each injected operation is a single
|
|
77
|
+
* bounded call, never a retry loop, inside this function; enforcing a hard
|
|
78
|
+
* per-operation timeout/cancellation on a slow `attemptLaunch` or
|
|
79
|
+
* `querySupportedModels` implementation is the caller's operation-budget
|
|
80
|
+
* responsibility and is intentionally out of scope for this pure primitive.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} options
|
|
83
|
+
* @param {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}} options.request
|
|
84
|
+
* @param {(request:object)=>({ok:true,launch:*}|{ok:false,reason:string})} options.attemptLaunch
|
|
85
|
+
* Called AT MOST ONCE.
|
|
86
|
+
* @param {(request:object)=>({supported:string[]}|string[]|Set<string>)} options.querySupportedModels
|
|
87
|
+
* Called AT MOST ONCE, and only after a failed launch.
|
|
88
|
+
* @param {()=>number} [options.now] - injectable clock, default Date.now.
|
|
89
|
+
* @param {number} [options.deadlineMs] - measurement bound in ms, default 60000.
|
|
90
|
+
* @returns {object} `{ ok: true, launch, events, elapsedMs, withinDeadline }`
|
|
91
|
+
* on success, or the durable blocker `{ ok: false, verdict: "blocked",
|
|
92
|
+
* reason, launchFailureDetail, request, modelSupported, elapsedMs,
|
|
93
|
+
* withinDeadline, events }` on failure. `launchFailureDetail` is the failed
|
|
94
|
+
* `attemptLaunch` result verbatim (its own reason plus any error/message it
|
|
95
|
+
* carried), preserved alongside the normalized `reason`.
|
|
96
|
+
*/
|
|
97
|
+
export function enforceChildLaunchBound({ request, attemptLaunch, querySupportedModels, now = () => Date.now(), deadlineMs = 60000 } = {}) {
|
|
98
|
+
const normalizedRequest = validateRequest(request);
|
|
99
|
+
if (typeof attemptLaunch !== "function") {
|
|
100
|
+
throw new TypeError("enforceChildLaunchBound requires attemptLaunch to be a function");
|
|
101
|
+
}
|
|
102
|
+
if (typeof querySupportedModels !== "function") {
|
|
103
|
+
throw new TypeError("enforceChildLaunchBound requires querySupportedModels to be a function");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const events = [];
|
|
107
|
+
const start = now();
|
|
108
|
+
|
|
109
|
+
// EXACTLY one launch attempt, ever — no retry, no model substitution.
|
|
110
|
+
const launchResult = attemptLaunch(normalizedRequest);
|
|
111
|
+
events.push({ type: "launch_attempt" });
|
|
112
|
+
|
|
113
|
+
if (launchResult && launchResult.ok === true) {
|
|
114
|
+
// Success: never query the inventory, never attempt a second launch.
|
|
115
|
+
const elapsedMs = now() - start;
|
|
116
|
+
return { ok: true, launch: launchResult.launch, events, elapsedMs, withinDeadline: elapsedMs <= deadlineMs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Launch failed: query the harness's supported-model inventory EXACTLY
|
|
120
|
+
// once, only now (never before a launch attempt, never more than once).
|
|
121
|
+
const inventory = querySupportedModels(normalizedRequest);
|
|
122
|
+
events.push({ type: "inventory_query" });
|
|
123
|
+
const modelSupported = toSupportedSet(inventory).has(normalizedRequest.model);
|
|
124
|
+
|
|
125
|
+
const launchReason = launchResult && typeof launchResult.reason === "string" ? launchResult.reason : null;
|
|
126
|
+
const reason = launchReason === "unresolvable"
|
|
127
|
+
? "child_model_unresolvable"
|
|
128
|
+
: !modelSupported
|
|
129
|
+
? "child_model_unsupported"
|
|
130
|
+
: "child_launch_failed_model_supported";
|
|
131
|
+
// Verbatim passthrough of the failed launch result (its own reason plus any
|
|
132
|
+
// error/message/detail it carried) — never inspected or altered — so a
|
|
133
|
+
// caller can see the exact failure detail alongside the normalized reason.
|
|
134
|
+
const launchFailureDetail = launchResult && typeof launchResult === "object" ? launchResult : null;
|
|
135
|
+
|
|
136
|
+
const elapsedMs = now() - start;
|
|
137
|
+
// Measurement, not enforcement: a slow adapter still returns the blocker
|
|
138
|
+
// (fail-closed) rather than being cancelled or retried against the clock.
|
|
139
|
+
const withinDeadline = elapsedMs <= deadlineMs;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
verdict: "blocked",
|
|
144
|
+
reason,
|
|
145
|
+
launchFailureDetail,
|
|
146
|
+
request: normalizedRequest,
|
|
147
|
+
modelSupported,
|
|
148
|
+
elapsedMs,
|
|
149
|
+
withinDeadline,
|
|
150
|
+
events,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -20,16 +20,33 @@ const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
|
|
|
20
20
|
export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The same workflow ALSO surfaces as
|
|
24
|
-
* (`gate-evidence-runner
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* The same workflow ALSO surfaces as check runs under its two job ids
|
|
24
|
+
* (`gate-evidence-runner`, the compute-heavy detector, and
|
|
25
|
+
* `gate-evidence-reporter`, the always-settling job that owns the status
|
|
26
|
+
* above — see docs/decisions/0076) beside the commit status named above, and
|
|
27
|
+
* all are the loop's own derived signal. Excluding only the status context
|
|
28
|
+
* left either job's conclusion gating the loop's own pre_approval step: once
|
|
29
|
+
* the workflow gained job-level concurrency, a superseded run is cancelled as
|
|
28
30
|
* normal operation, and a cancelled run is deliberately NOT treated as green
|
|
29
31
|
* (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
|
|
30
32
|
* the whole head read "none" and the loop waited on CI forever.
|
|
31
33
|
*/
|
|
32
|
-
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
|
|
34
|
+
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
|
|
35
|
+
LOOP_DERIVED_CI_CHECK_NAME,
|
|
36
|
+
"gate-evidence-runner",
|
|
37
|
+
"gate-evidence-reporter",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The two Gate-evidence JOB check-run names — the detector and the reporter.
|
|
42
|
+
* These (and ONLY these, never the `gate-evidence` status name) are the runs
|
|
43
|
+
* whose superseded CANCELLED check-runs `classifyBenignGateEvidenceUnstable`
|
|
44
|
+
* treats as cosmetic UNSTABLE noise.
|
|
45
|
+
*/
|
|
46
|
+
export const GATE_EVIDENCE_JOB_CHECK_NAMES = Object.freeze([
|
|
47
|
+
"gate-evidence-runner",
|
|
48
|
+
"gate-evidence-reporter",
|
|
49
|
+
]);
|
|
33
50
|
|
|
34
51
|
function checkEntryName(entry) {
|
|
35
52
|
if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
|
|
@@ -141,6 +158,27 @@ export function normalizeStatusCheckRollupStatus(rollup) {
|
|
|
141
158
|
return "none";
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the normalized status of ONE named context/check within a
|
|
163
|
+
* `statusCheckRollup` (or check-runs-shaped) payload — e.g. whether the
|
|
164
|
+
* required `gate-evidence` context itself (as opposed to the loop's own
|
|
165
|
+
* exclusion of it, see `deriveLoopCiStatusFromRollup`) is success, failure,
|
|
166
|
+
* pending, or absent. Reuses the same name matching
|
|
167
|
+
* (`partitionEntriesByCheckName`) and state normalization
|
|
168
|
+
* (`normalizeStatusCheckRollupStatus`) the rollup helpers on this module
|
|
169
|
+
* already use, so a caller that needs one context's own state (e.g.
|
|
170
|
+
* merge-pr.mjs naming the real cause of a block on `gate-evidence`) does not
|
|
171
|
+
* re-derive name matching or status normalization.
|
|
172
|
+
*
|
|
173
|
+
* @param {Array<object>} rollup
|
|
174
|
+
* @param {string} contextName
|
|
175
|
+
* @returns {"success"|"failure"|"pending"|"none"}
|
|
176
|
+
*/
|
|
177
|
+
export function resolveNamedContextState(rollup, contextName) {
|
|
178
|
+
const { matched } = partitionEntriesByCheckName(rollup, contextName);
|
|
179
|
+
return normalizeStatusCheckRollupStatus(matched);
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
/**
|
|
145
183
|
* Summarize the GitHub check-runs API payload for one head SHA.
|
|
146
184
|
*
|
|
@@ -317,6 +355,78 @@ export function normalizeHeadScopedCiContract({
|
|
|
317
355
|
return buildCiContract(overallStatus);
|
|
318
356
|
}
|
|
319
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Classify a `mergeStateStatus === "UNSTABLE"` as BENIGN when the required
|
|
360
|
+
* `gate-evidence` commit status is itself `success` and the ONLY non-success
|
|
361
|
+
* rollup entries are superseded Gate-evidence job check-runs (the
|
|
362
|
+
* `gate-evidence-runner` detector OR the `gate-evidence-reporter`, conclusion
|
|
363
|
+
* `CANCELLED`).
|
|
364
|
+
*
|
|
365
|
+
* Both jobs cancel superseded runs: the detector via `cancel-in-progress`, the
|
|
366
|
+
* reporter via its non-cancelling group cancelling a still-queued run superseded
|
|
367
|
+
* by a newer one. Each leaves a `cancelled` check-run on the head, so
|
|
368
|
+
* `mergeStateStatus` reads `UNSTABLE` on nearly every PR even when the required
|
|
369
|
+
* `gate-evidence` status on the head is green. The cancellation is correct and
|
|
370
|
+
* stays (docs/decisions/0076); this classifier only lets a reader distinguish
|
|
371
|
+
* that cosmetic noise from a real non-success.
|
|
372
|
+
*
|
|
373
|
+
* Fail-closed: only an actual `UNSTABLE` with a `success` `gate-evidence` status
|
|
374
|
+
* and no other non-success entry is benign. A failed (not cancelled) Gate-evidence
|
|
375
|
+
* job, a non-success `gate-evidence` status, or any other failing/pending check
|
|
376
|
+
* makes it non-benign.
|
|
377
|
+
*
|
|
378
|
+
* ponytail: `gh pr view --json statusCheckRollup` returns a single bounded page
|
|
379
|
+
* (~100 contexts); a very chatty PR could exceed it and hide a real failure,
|
|
380
|
+
* failing this open. Acceptable because this is a display-only surface — the
|
|
381
|
+
* merge path never consults it. Do NOT wire this classifier into a merge
|
|
382
|
+
* decision without adding pagination.
|
|
383
|
+
*
|
|
384
|
+
* @param {Array<object>} rollup A `gh pr view --json statusCheckRollup` payload.
|
|
385
|
+
* @param {string|null} mergeStateStatus
|
|
386
|
+
* @returns {{ benign: boolean, reason: string }}
|
|
387
|
+
*/
|
|
388
|
+
export function classifyBenignGateEvidenceUnstable(rollup, mergeStateStatus) {
|
|
389
|
+
const state = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : "";
|
|
390
|
+
if (state !== "UNSTABLE") {
|
|
391
|
+
return { benign: false, reason: "mergeStateStatus is not UNSTABLE" };
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(rollup)) {
|
|
394
|
+
return { benign: false, reason: "status rollup unavailable" };
|
|
395
|
+
}
|
|
396
|
+
// The required gate-evidence signal is a commit STATUS (a StatusContext,
|
|
397
|
+
// `.context`), never a check-run (`.name`). Anchor the success guard on the
|
|
398
|
+
// StatusContext alone so a same-named success check-run can never stand in for
|
|
399
|
+
// an absent required status (partitionEntriesByCheckName matches `.name` OR
|
|
400
|
+
// `.context`, so it would otherwise accept either).
|
|
401
|
+
const gateEvidenceStatusEntries = rollup.filter(
|
|
402
|
+
(entry) => entry?.context === LOOP_DERIVED_CI_CHECK_NAME && typeof entry?.state === "string",
|
|
403
|
+
);
|
|
404
|
+
if (normalizeStatusCheckRollupStatus(gateEvidenceStatusEntries) !== "success") {
|
|
405
|
+
return { benign: false, reason: "gate-evidence status is not success" };
|
|
406
|
+
}
|
|
407
|
+
const offenders = [];
|
|
408
|
+
for (const entry of rollup) {
|
|
409
|
+
if (normalizeStatusCheckRollupStatus([entry]) === "success") continue;
|
|
410
|
+
const name = checkEntryName(entry);
|
|
411
|
+
const conclusion = typeof entry?.conclusion === "string" ? entry.conclusion.toUpperCase() : "";
|
|
412
|
+
// Only a cancelled Gate-evidence JOB check-run (runner or reporter) is the
|
|
413
|
+
// superseded-run artifact this classifier ignores — an explicit two-job
|
|
414
|
+
// allowlist, not LOOP_DERIVED_CI_CHECK_NAMES (which also carries the
|
|
415
|
+
// `gate-evidence` status name), so a cancelled entry named `gate-evidence`
|
|
416
|
+
// can never be waved through as benign.
|
|
417
|
+
if (GATE_EVIDENCE_JOB_CHECK_NAMES.includes(name) && conclusion === "CANCELLED") continue;
|
|
418
|
+
const status = typeof entry?.status === "string" ? entry.status.toUpperCase() : "";
|
|
419
|
+
offenders.push(`${name || "unknown"}=${conclusion || status || "?"}`);
|
|
420
|
+
}
|
|
421
|
+
if (offenders.length > 0) {
|
|
422
|
+
return { benign: false, reason: `non-benign non-success checks present: ${offenders.join(", ")}` };
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
benign: true,
|
|
426
|
+
reason: "no non-success entry other than superseded Gate-evidence job cancellations; gate-evidence status is success",
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
320
430
|
/**
|
|
321
431
|
* Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
|
|
322
432
|
* `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
|
|
@@ -197,6 +197,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
197
197
|
lastCopilotRoundMaxSignal = null,
|
|
198
198
|
failureDetails = [],
|
|
199
199
|
excludedFailureDetails,
|
|
200
|
+
copilotBodyFeedbackUnresolved = false,
|
|
200
201
|
}) {
|
|
201
202
|
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
202
203
|
const prMerged = prState === "MERGED";
|
|
@@ -205,6 +206,9 @@ export function buildSnapshotFromPrFacts({
|
|
|
205
206
|
// that never threads an explicit ciStatus (e.g. gate-coordination detection)
|
|
206
207
|
// still never treats it as a blocking CI failure.
|
|
207
208
|
const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
|
|
209
|
+
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
210
|
+
? prData.headRefOid.trim()
|
|
211
|
+
: null;
|
|
208
212
|
|
|
209
213
|
return normalizeSnapshot({
|
|
210
214
|
prExists: true,
|
|
@@ -212,6 +216,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
212
216
|
prDraft: Boolean(prData?.isDraft),
|
|
213
217
|
prMerged,
|
|
214
218
|
prClosed,
|
|
219
|
+
currentHeadSha,
|
|
215
220
|
copilotReviewRequestStatus,
|
|
216
221
|
copilotReviewPresent,
|
|
217
222
|
copilotReviewOnCurrentHead,
|
|
@@ -222,6 +227,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
222
227
|
ciStatus: ciStatus ?? rollupDerivation.status,
|
|
223
228
|
failureDetails,
|
|
224
229
|
excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
|
|
230
|
+
copilotBodyFeedbackUnresolved,
|
|
225
231
|
});
|
|
226
232
|
}
|
|
227
233
|
|
|
@@ -269,6 +275,9 @@ export function normalizeSnapshot(raw) {
|
|
|
269
275
|
prDraft: Boolean(raw.prDraft),
|
|
270
276
|
prMerged: Boolean(raw.prMerged),
|
|
271
277
|
prClosed: Boolean(raw.prClosed),
|
|
278
|
+
currentHeadSha: typeof raw.currentHeadSha === "string" && raw.currentHeadSha.trim().length > 0
|
|
279
|
+
? raw.currentHeadSha.trim()
|
|
280
|
+
: null,
|
|
272
281
|
copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
|
|
273
282
|
? raw.copilotReviewRequestStatus
|
|
274
283
|
: "none",
|
|
@@ -288,6 +297,7 @@ export function normalizeSnapshot(raw) {
|
|
|
288
297
|
agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
|
|
289
298
|
failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
|
|
290
299
|
excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
|
|
300
|
+
copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
|
|
291
301
|
};
|
|
292
302
|
}
|
|
293
303
|
|
|
@@ -397,7 +407,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
397
407
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
398
408
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
399
409
|
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
|
|
400
|
-
const cleanThreads = s.unresolvedThreadCount === 0;
|
|
410
|
+
const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
|
|
401
411
|
if (cleanThreads && ciClean) {
|
|
402
412
|
state = STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
403
413
|
} else if (!reviewInFlight) {
|
|
@@ -406,11 +416,16 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
406
416
|
// Not clean WITH an in-flight request: leave state undecided for the routing below.
|
|
407
417
|
}
|
|
408
418
|
|
|
419
|
+
// Unresolved feedback includes both inline review threads and a Copilot
|
|
420
|
+
// review-body finding on the current head (a body-only "Changes recommended"
|
|
421
|
+
// review with zero inline threads must not read as clean).
|
|
422
|
+
const unresolvedFeedback = s.unresolvedThreadCount > 0 || s.copilotBodyFeedbackUnresolved;
|
|
423
|
+
|
|
409
424
|
if (state === undefined) {
|
|
410
|
-
if (
|
|
425
|
+
if (unresolvedFeedback && s.agentFixStatus === "applied") {
|
|
411
426
|
// Agent has fixed the code; threads still need reply/resolve on GitHub
|
|
412
427
|
state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
|
|
413
|
-
} else if (
|
|
428
|
+
} else if (unresolvedFeedback) {
|
|
414
429
|
// Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
|
|
415
430
|
state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
|
|
416
431
|
} else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
|
|
@@ -459,7 +474,8 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
459
474
|
const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
|
|
460
475
|
&& s.copilotReviewOnCurrentHead
|
|
461
476
|
&& s.unresolvedThreadCount === 0
|
|
462
|
-
&& s.actionableThreadCount === 0
|
|
477
|
+
&& s.actionableThreadCount === 0
|
|
478
|
+
&& !s.copilotBodyFeedbackUnresolved;
|
|
463
479
|
|
|
464
480
|
let nextAction = NEXT_ACTIONS[state];
|
|
465
481
|
if (sameHeadCleanConverged) {
|