@nickysagan/issue-orchestrator 0.1.2 → 0.2.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/README.md +99 -215
- package/bin/supervisor.mjs +497 -29
- package/package.json +1 -1
- package/src/enforcementNotice.mjs +36 -0
- package/src/managedContainer.mjs +51 -4
- package/src/repairGate.mjs +57 -0
- package/src/repairMarker.mjs +248 -0
- package/src/reviewGate.mjs +18 -8
- package/src/workerLogs.mjs +14 -10
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Sentinel's enforcement state is a lease field, not an admission decision.
|
|
2
|
+
// This module only decides what to say about a change in it, and whether the
|
|
3
|
+
// change still needs acknowledging; it reads no usage and gates nothing.
|
|
4
|
+
const WINDOW_NAMES = { short: "5-hour", long: "weekly" };
|
|
5
|
+
|
|
6
|
+
function pauseMessage(reason) {
|
|
7
|
+
const window = WINDOW_NAMES[reason.window];
|
|
8
|
+
const short = Math.round(reason.shortUsedPercent);
|
|
9
|
+
const long = Math.round(reason.longUsedPercent);
|
|
10
|
+
const resets = reason.resetsAt === null ? "" : ` Usage resets at ${reason.resetsAt}.`;
|
|
11
|
+
return (
|
|
12
|
+
`Sentinel usage threshold reached on the ${window} window ` +
|
|
13
|
+
`(5-hour ${short}%, weekly ${long}%); pausing this container until usage ` +
|
|
14
|
+
`becomes available.${resets}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function describeEnforcementTransition(previousState, enforcement) {
|
|
19
|
+
const state = enforcement.state;
|
|
20
|
+
// Acknowledgment is independent of the message: a pause announced on one
|
|
21
|
+
// heartbeat whose acknowledgment failed is retried on the next one without
|
|
22
|
+
// printing a second line.
|
|
23
|
+
const acknowledge = state === "pause_pending" && enforcement.acknowledged !== true;
|
|
24
|
+
if (state === previousState) return { state, message: null, acknowledge };
|
|
25
|
+
if (state === "running") {
|
|
26
|
+
return { state, message: "Sentinel unpaused this container; issue-orchestrator resumed.", acknowledge };
|
|
27
|
+
}
|
|
28
|
+
if (previousState === "running") {
|
|
29
|
+
// Announcing on any first departure from `running` covers a lease seen as
|
|
30
|
+
// `paused` outright — a Sentinel restart, or a pause whose persist raced —
|
|
31
|
+
// which would otherwise freeze the container in silence.
|
|
32
|
+
return { state, message: pauseMessage(enforcement.reason), acknowledge };
|
|
33
|
+
}
|
|
34
|
+
// pause_pending → paused: the same pause, already announced.
|
|
35
|
+
return { state, message: null, acknowledge };
|
|
36
|
+
}
|
package/src/managedContainer.mjs
CHANGED
|
@@ -19,6 +19,41 @@ export async function resolveContainerId({ readFileImpl = readFile } = {}) {
|
|
|
19
19
|
throw new Error(`ambiguous Docker container IDs in /proc/self/mountinfo: ${[...ids].join(", ")}`);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const WINDOWS = new Set(["short", "long"]);
|
|
23
|
+
|
|
24
|
+
function isPercent(value) {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isReason(value) {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === "object" && value !== null &&
|
|
31
|
+
WINDOWS.has(value.window) &&
|
|
32
|
+
isPercent(value.shortUsedPercent) &&
|
|
33
|
+
isPercent(value.longUsedPercent) &&
|
|
34
|
+
(value.resetsAt === null || typeof value.resetsAt === "string")
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Sentinel documents an absent field as `running`, so only a *present* payload
|
|
39
|
+
// can be malformed. A malformed one throws: this client never invents a state,
|
|
40
|
+
// because a wrong guess would either hide an imminent pause or announce one
|
|
41
|
+
// that is not coming.
|
|
42
|
+
function parseEnforcement(value) {
|
|
43
|
+
if (value === undefined || value === null) return { state: "running" };
|
|
44
|
+
if (typeof value !== "object") {
|
|
45
|
+
throw new Error("invalid managed-container enforcement: not an object");
|
|
46
|
+
}
|
|
47
|
+
if (value.state === "running") return { state: "running" };
|
|
48
|
+
if (value.state !== "pause_pending" && value.state !== "paused") {
|
|
49
|
+
throw new Error(`invalid managed-container enforcement: unknown state ${value.state}`);
|
|
50
|
+
}
|
|
51
|
+
if (!isReason(value.reason)) {
|
|
52
|
+
throw new Error("invalid managed-container enforcement: invalid reason");
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
22
57
|
// Sentinel's `/managed-containers` API is a lease, not admission control: it
|
|
23
58
|
// never allows or denies a start, so neither operation here returns anything a
|
|
24
59
|
// caller could read as a decision. Any non-lease status is a local or transport
|
|
@@ -61,12 +96,12 @@ export function createManagedContainerClient({
|
|
|
61
96
|
return new Error(`${prefix}: ${detail}`);
|
|
62
97
|
}
|
|
63
98
|
|
|
64
|
-
async function request(containerId, method) {
|
|
99
|
+
async function request(containerId, method, suffix = "") {
|
|
65
100
|
if (typeof containerId !== "string" || !CONTAINER_ID.test(containerId)) {
|
|
66
101
|
throw new Error(`invalid container ID: ${containerId}`);
|
|
67
102
|
}
|
|
68
103
|
const { signal, done } = startDeadline();
|
|
69
|
-
const path = `/managed-containers/${encodeURIComponent(containerId)}`;
|
|
104
|
+
const path = `/managed-containers/${encodeURIComponent(containerId)}${suffix}`;
|
|
70
105
|
let response;
|
|
71
106
|
try {
|
|
72
107
|
response = await withDeadline(fetchImpl(`${baseUrl}${path}`, { method, signal }), signal);
|
|
@@ -102,12 +137,24 @@ export function createManagedContainerClient({
|
|
|
102
137
|
if (body?.status !== expected) {
|
|
103
138
|
throw new Error(`invalid managed-container registration response: status ${body?.status}`);
|
|
104
139
|
}
|
|
105
|
-
return { status: expected };
|
|
140
|
+
return { status: expected, enforcement: parseEnforcement(body.enforcement) };
|
|
106
141
|
} finally {
|
|
107
142
|
done();
|
|
108
143
|
}
|
|
109
144
|
}
|
|
110
145
|
|
|
146
|
+
// Tells Sentinel the pre-pause message has been printed, so it may pause this
|
|
147
|
+
// container. `409 no pending pause` is not a failure: it only means Sentinel
|
|
148
|
+
// already resolved the transition — its acknowledgment timeout fired, or usage
|
|
149
|
+
// dropped back below the threshold.
|
|
150
|
+
async function acknowledge(containerId) {
|
|
151
|
+
const { response, done } = await request(containerId, "POST", "/acknowledge");
|
|
152
|
+
done();
|
|
153
|
+
if (response.status === 200) return { acknowledged: true };
|
|
154
|
+
if (response.status === 409) return { acknowledged: false };
|
|
155
|
+
throw new Error(`unexpected managed-container acknowledgment status ${response.status}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
async function unregister(containerId) {
|
|
112
159
|
const { response, done } = await request(containerId, "DELETE");
|
|
113
160
|
done();
|
|
@@ -116,5 +163,5 @@ export function createManagedContainerClient({
|
|
|
116
163
|
}
|
|
117
164
|
}
|
|
118
165
|
|
|
119
|
-
return { register, unregister };
|
|
166
|
+
return { register, unregister, acknowledge };
|
|
120
167
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BLOCKED, PHASE_LABELS, REVIEW, RUNNING } from "./labels.mjs";
|
|
2
|
+
import { selectApplicableMarker } from "./reviewMarker.mjs";
|
|
3
|
+
import {
|
|
4
|
+
selectLatestTrustedReview, selectLatestTrustedReviewContext,
|
|
5
|
+
selectRepairHistory, selectRepairReservation,
|
|
6
|
+
} from "./repairMarker.mjs";
|
|
7
|
+
|
|
8
|
+
export function exactPhase(labels, phase) {
|
|
9
|
+
const set = new Set(labels || []);
|
|
10
|
+
return set.has(RUNNING) && set.has(phase)
|
|
11
|
+
&& PHASE_LABELS.filter((label) => set.has(label)).length === 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function planRepair({ issue, pr, comments, phase, liveRepair, maxAttempts }) {
|
|
15
|
+
if (![BLOCKED, REVIEW].includes(phase)) return { action: "skip" };
|
|
16
|
+
if (!liveRepair && exactPhase(issue.labels, REVIEW) && exactPhase(pr.labels, REVIEW)) return { action: "skip" };
|
|
17
|
+
|
|
18
|
+
const identity = { issue: issue.number, pr: pr.number };
|
|
19
|
+
const history = selectRepairHistory(comments, identity);
|
|
20
|
+
const latestReview = selectLatestTrustedReview(comments, identity);
|
|
21
|
+
const latestContext = selectLatestTrustedReviewContext(comments, identity);
|
|
22
|
+
if (!history.valid) return { action: "invalid-history", history, latestReview };
|
|
23
|
+
const reservation = latestReview?.verdict === "BLOCKING"
|
|
24
|
+
? selectRepairReservation(comments, identity, latestReview.fingerprint)
|
|
25
|
+
: null;
|
|
26
|
+
if (reservation) {
|
|
27
|
+
if (liveRepair) return { action: "live", reservation };
|
|
28
|
+
if (reservation.head !== pr.headRefOid) {
|
|
29
|
+
return { action: "review", reason: "reserved-head-changed", reservation };
|
|
30
|
+
}
|
|
31
|
+
return { action: "failed", reservation };
|
|
32
|
+
}
|
|
33
|
+
if (phase !== BLOCKED || !exactPhase(issue.labels, BLOCKED) || !exactPhase(pr.labels, BLOCKED)) {
|
|
34
|
+
return { action: "skip" };
|
|
35
|
+
}
|
|
36
|
+
const marker = selectApplicableMarker(comments, {
|
|
37
|
+
...identity, head: pr.headRefOid, base: pr.baseRefOid,
|
|
38
|
+
issueBody: issue.body, prBody: pr.body,
|
|
39
|
+
});
|
|
40
|
+
const applicable = marker && latestReview
|
|
41
|
+
&& marker.fingerprint === latestReview.fingerprint && marker.pass === latestReview.pass;
|
|
42
|
+
if (!applicable || marker.verdict !== "BLOCKING") {
|
|
43
|
+
return latestReview?.verdict === "BLOCKING"
|
|
44
|
+
? { action: "review", reason: "stale-review-snapshot" }
|
|
45
|
+
: { action: "skip" };
|
|
46
|
+
}
|
|
47
|
+
if (!pr.isDraft) return { action: "skip" };
|
|
48
|
+
if (history.attempts.length >= maxAttempts) {
|
|
49
|
+
return { action: "budget", marker, history };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
action: "eligible", marker, history,
|
|
53
|
+
attempt: history.attempts.length + 1,
|
|
54
|
+
sourceFingerprint: marker.fingerprint,
|
|
55
|
+
blockingCreatedAt: latestContext?.createdAt,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { parseReviewMarkers } from "./reviewMarker.mjs";
|
|
2
|
+
|
|
3
|
+
export const REPAIR_MARKER_TAG = "issue-orchestrator:repair-attempt v1";
|
|
4
|
+
|
|
5
|
+
const ATTEMPT_PATTERN = /^<!-- issue-orchestrator:repair-attempt v1 (\{.*\}) -->$/m;
|
|
6
|
+
const ATTEMPT_FIELDS = ["issue", "pr", "attempt", "sourceFingerprint", "head", "log"];
|
|
7
|
+
const FAILURE_FIELDS = ["issue", "pr", "attempt", "sourceFingerprint"];
|
|
8
|
+
const BUDGET_FIELDS = ["issue", "pr", "sourceFingerprint"];
|
|
9
|
+
const INVALID_HISTORY_FIELDS = ["issue", "pr", "sourceFingerprint"];
|
|
10
|
+
const FAILURE_PATTERN = /^<!-- issue-orchestrator:repair-failure v1 (\{.*\}) -->$/m;
|
|
11
|
+
const BUDGET_PATTERN = /^<!-- issue-orchestrator:repair-budget-exhausted v1 (\{.*\}) -->$/m;
|
|
12
|
+
const INVALID_HISTORY_PATTERN = /^<!-- issue-orchestrator:repair-history-invalid v1 (\{.*\}) -->$/m;
|
|
13
|
+
|
|
14
|
+
function positiveInteger(value) {
|
|
15
|
+
return Number.isInteger(value) && value > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validIdentity(value) {
|
|
19
|
+
return value && positiveInteger(value.issue) && positiveInteger(value.pr);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fingerprint = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
23
|
+
const commit = (value) => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
24
|
+
|
|
25
|
+
function exactPayload(value, fields) {
|
|
26
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
27
|
+
&& Object.keys(value).length === fields.length
|
|
28
|
+
&& fields.every((field) => Object.hasOwn(value, field));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function attemptPayload(value) {
|
|
32
|
+
if (!exactPayload(value, ATTEMPT_FIELDS)) return null;
|
|
33
|
+
if (!positiveInteger(value.issue) || !positiveInteger(value.pr) || !positiveInteger(value.attempt)) return null;
|
|
34
|
+
if (!fingerprint(value.sourceFingerprint) || !commit(value.head)) return null;
|
|
35
|
+
if (value.log !== null && (typeof value.log !== "string" || value.log.length === 0)) return null;
|
|
36
|
+
return Object.fromEntries(ATTEMPT_FIELDS.map((field) => [field, value[field]]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseRepairAttempt(body) {
|
|
40
|
+
const match = ATTEMPT_PATTERN.exec(String(body ?? ""));
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
try {
|
|
43
|
+
return attemptPayload(JSON.parse(match[1]));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trustedAttempt(comment, identity) {
|
|
50
|
+
if (comment?.viewerDidAuthor !== true) return null;
|
|
51
|
+
const marker = parseRepairAttempt(comment.body);
|
|
52
|
+
if (!marker || marker.issue !== identity.issue || marker.pr !== identity.pr) return null;
|
|
53
|
+
return marker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function selectRepairReservation(comments, identity, sourceFingerprint) {
|
|
57
|
+
if (!validIdentity(identity) || typeof sourceFingerprint !== "string" || sourceFingerprint.length === 0) return null;
|
|
58
|
+
let found = null;
|
|
59
|
+
for (const comment of comments || []) {
|
|
60
|
+
const marker = trustedAttempt(comment, identity);
|
|
61
|
+
if (marker?.sourceFingerprint === sourceFingerprint) found = marker;
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function trustedReviews(comment, identity) {
|
|
67
|
+
if (comment?.viewerDidAuthor !== true) return [];
|
|
68
|
+
return parseReviewMarkers(comment.body).filter((marker) => (
|
|
69
|
+
marker.issue === identity.issue && marker.pr === identity.pr
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function selectLatestTrustedReview(comments, identity) {
|
|
74
|
+
if (!validIdentity(identity)) return null;
|
|
75
|
+
let found = null;
|
|
76
|
+
for (const comment of comments || []) {
|
|
77
|
+
for (const marker of trustedReviews(comment, identity)) found = marker;
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function selectLatestTrustedReviewContext(comments, identity) {
|
|
83
|
+
if (!validIdentity(identity)) return null;
|
|
84
|
+
let found = null;
|
|
85
|
+
for (const comment of comments || []) {
|
|
86
|
+
for (const marker of trustedReviews(comment, identity)) {
|
|
87
|
+
found = { marker, createdAt: comment.createdAt, url: comment.url };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function selectRepairHistory(comments, identity) {
|
|
94
|
+
if (!validIdentity(identity)) return { valid: false, attempts: [], blockingPasses: [] };
|
|
95
|
+
const all = Array.from(comments || []);
|
|
96
|
+
let start = 0;
|
|
97
|
+
for (let index = 0; index < all.length; index += 1) {
|
|
98
|
+
if (trustedReviews(all[index], identity).some(({ verdict }) => verdict === "PASS")) start = index + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const attempts = [];
|
|
102
|
+
const blockingPasses = [];
|
|
103
|
+
const blockingSources = new Set();
|
|
104
|
+
const attemptedSources = new Set();
|
|
105
|
+
let valid = true;
|
|
106
|
+
for (const comment of all.slice(start)) {
|
|
107
|
+
for (const marker of trustedReviews(comment, identity)) {
|
|
108
|
+
if (marker.verdict === "BLOCKING") {
|
|
109
|
+
blockingPasses.push({ fingerprint: marker.fingerprint, pass: marker.pass, url: comment.url });
|
|
110
|
+
blockingSources.add(`${marker.fingerprint}\0${marker.head}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const marker = trustedAttempt(comment, identity);
|
|
114
|
+
if (!marker) continue;
|
|
115
|
+
if (!blockingSources.has(`${marker.sourceFingerprint}\0${marker.head}`)) continue;
|
|
116
|
+
if (attemptedSources.has(marker.sourceFingerprint)) {
|
|
117
|
+
valid = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (marker.attempt !== attempts.length + 1) {
|
|
121
|
+
valid = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
attempts.push(marker);
|
|
125
|
+
attemptedSources.add(marker.sourceFingerprint);
|
|
126
|
+
}
|
|
127
|
+
return { valid, attempts, blockingPasses };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasTrustedMarker(comments, identity, pattern, fields, validPayload, matches) {
|
|
131
|
+
for (const comment of comments || []) {
|
|
132
|
+
if (comment?.viewerDidAuthor !== true) continue;
|
|
133
|
+
const match = pattern.exec(String(comment.body ?? ""));
|
|
134
|
+
if (!match) continue;
|
|
135
|
+
try {
|
|
136
|
+
const marker = JSON.parse(match[1]);
|
|
137
|
+
if (exactPayload(marker, fields) && validPayload(marker)
|
|
138
|
+
&& marker.issue === identity.issue && marker.pr === identity.pr
|
|
139
|
+
&& matches(marker)) return true;
|
|
140
|
+
} catch {
|
|
141
|
+
// Malformed comments are untrusted state, never durable evidence.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function hasRepairFailure(comments, identity, attempt, sourceFingerprint) {
|
|
148
|
+
if (!validIdentity(identity) || !positiveInteger(attempt) || !fingerprint(sourceFingerprint)) return false;
|
|
149
|
+
return hasTrustedMarker(
|
|
150
|
+
comments, identity, FAILURE_PATTERN, FAILURE_FIELDS,
|
|
151
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
152
|
+
&& positiveInteger(marker.attempt) && fingerprint(marker.sourceFingerprint),
|
|
153
|
+
(marker) => marker.attempt === attempt && marker.sourceFingerprint === sourceFingerprint,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasRepairBudgetComment(comments, identity, sourceFingerprint) {
|
|
158
|
+
if (!validIdentity(identity) || !fingerprint(sourceFingerprint)) return false;
|
|
159
|
+
return hasTrustedMarker(
|
|
160
|
+
comments, identity, BUDGET_PATTERN, BUDGET_FIELDS,
|
|
161
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
162
|
+
&& fingerprint(marker.sourceFingerprint),
|
|
163
|
+
(marker) => marker.sourceFingerprint === sourceFingerprint,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hasInvalidRepairHistoryComment(comments, identity, sourceFingerprint) {
|
|
168
|
+
if (!validIdentity(identity) || !fingerprint(sourceFingerprint)) return false;
|
|
169
|
+
return hasTrustedMarker(
|
|
170
|
+
comments, identity, INVALID_HISTORY_PATTERN, INVALID_HISTORY_FIELDS,
|
|
171
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
172
|
+
&& fingerprint(marker.sourceFingerprint),
|
|
173
|
+
(marker) => marker.sourceFingerprint === sourceFingerprint,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requireCommentInput(input, { attempt = false } = {}) {
|
|
178
|
+
if (!validIdentity(input)) throw new TypeError("issue and pr must be positive integers");
|
|
179
|
+
if (!fingerprint(input.sourceFingerprint)) {
|
|
180
|
+
throw new TypeError("sourceFingerprint must be 64 lowercase hex characters");
|
|
181
|
+
}
|
|
182
|
+
if (attempt && !positiveInteger(input.attempt)) throw new TypeError("attempt must be a positive integer");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hidden(tag, payload) {
|
|
186
|
+
return `<!-- ${tag} ${JSON.stringify(payload)} -->`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatRepairAttemptComment(input) {
|
|
190
|
+
requireCommentInput(input, { attempt: true });
|
|
191
|
+
if (!commit(input.head)) throw new TypeError("head must be 40 hex characters");
|
|
192
|
+
if (input.log !== undefined && input.log !== null
|
|
193
|
+
&& (typeof input.log !== "string" || input.log.length === 0)) {
|
|
194
|
+
throw new TypeError("log must be a nonempty string or null");
|
|
195
|
+
}
|
|
196
|
+
if (!positiveInteger(input.maxAttempts)) throw new TypeError("maxAttempts must be a positive integer");
|
|
197
|
+
const marker = {
|
|
198
|
+
issue: input.issue, pr: input.pr, attempt: input.attempt,
|
|
199
|
+
sourceFingerprint: input.sourceFingerprint, head: input.head,
|
|
200
|
+
log: input.log ?? null,
|
|
201
|
+
};
|
|
202
|
+
return [
|
|
203
|
+
`Automatic repair attempt ${input.attempt} of ${input.maxAttempts} reserved for review source \`${input.sourceFingerprint}\`.`,
|
|
204
|
+
"",
|
|
205
|
+
hidden(REPAIR_MARKER_TAG, marker),
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formatRepairFailureComment(input) {
|
|
210
|
+
requireCommentInput(input, { attempt: true });
|
|
211
|
+
const details = [];
|
|
212
|
+
if (input.exitCode !== undefined) details.push(`Exit code ${input.exitCode}.`);
|
|
213
|
+
if (typeof input.logUrl === "string" && input.logUrl.length > 0) details.push(`Logs: ${input.logUrl}`);
|
|
214
|
+
else details.push("Log location unavailable.");
|
|
215
|
+
return [
|
|
216
|
+
`Automatic repair attempt ${input.attempt} failed for review source \`${input.sourceFingerprint}\`.`,
|
|
217
|
+
...details,
|
|
218
|
+
"",
|
|
219
|
+
hidden("issue-orchestrator:repair-failure v1", {
|
|
220
|
+
issue: input.issue, pr: input.pr, attempt: input.attempt, sourceFingerprint: input.sourceFingerprint,
|
|
221
|
+
}),
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function formatRepairBudgetComment(input) {
|
|
226
|
+
requireCommentInput(input);
|
|
227
|
+
if (!positiveInteger(input.maxAttempts)) throw new TypeError("maxAttempts must be a positive integer");
|
|
228
|
+
const urls = Array.from(input.blockingReviewUrls || []).filter((url) => typeof url === "string" && url.length > 0);
|
|
229
|
+
return [
|
|
230
|
+
`${input.maxAttempts} automatic repair attempts have been exhausted for review source \`${input.sourceFingerprint}\`.`,
|
|
231
|
+
...(urls.length > 0 ? ["", "Blocking review passes:", ...urls.map((url) => `- ${url}`)] : []),
|
|
232
|
+
"",
|
|
233
|
+
hidden("issue-orchestrator:repair-budget-exhausted v1", {
|
|
234
|
+
issue: input.issue, pr: input.pr, sourceFingerprint: input.sourceFingerprint,
|
|
235
|
+
}),
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatInvalidRepairHistoryComment(input) {
|
|
240
|
+
requireCommentInput(input);
|
|
241
|
+
return [
|
|
242
|
+
`Invalid automatic repair history detected for review source \`${input.sourceFingerprint}\`. Manual intervention is required.`,
|
|
243
|
+
"",
|
|
244
|
+
hidden("issue-orchestrator:repair-history-invalid v1", {
|
|
245
|
+
issue: input.issue, pr: input.pr, sourceFingerprint: input.sourceFingerprint,
|
|
246
|
+
}),
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
package/src/reviewGate.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BLOCKED, MANAGED_LABELS, MERGE_REVIEW, REVIEW, phaseOf } from "./labels.mjs";
|
|
1
|
+
import { BLOCKED, MANAGED_LABELS, MERGE_REVIEW, PHASE_LABELS, REVIEW, phaseOf } from "./labels.mjs";
|
|
2
2
|
|
|
3
3
|
const BRANCH_PREFIX = /^agent\/(\d+)-/;
|
|
4
4
|
|
|
@@ -188,13 +188,23 @@ export function planLabelMirror(issueLabels, prLabels) {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// Swap the issue's phase label and mirror it onto the PR. `agent-running` is
|
|
191
|
-
// never removed here — it
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
// never removed here — it survives every phase swap, and only the supervisor
|
|
192
|
+
// releases it, once the PR is ready for the owner.
|
|
193
|
+
export async function setPhase({ gh, issue, pr, phase }) {
|
|
194
|
+
const deltaFor = (labels) => {
|
|
195
|
+
const current = new Set(labels || []);
|
|
196
|
+
return {
|
|
197
|
+
add: current.has(phase) ? [] : [phase],
|
|
198
|
+
remove: PHASE_LABELS.filter((label) => label !== phase && current.has(label)),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
const issueDelta = deltaFor(issue.labels);
|
|
202
|
+
if (issueDelta.add.length || issueDelta.remove.length) await gh.setIssueLabels(issue.number, issueDelta);
|
|
203
|
+
if (pr) {
|
|
204
|
+
if (!Array.isArray(pr.labels)) throw new Error("PR labels must be provided");
|
|
205
|
+
const prDelta = deltaFor(pr.labels);
|
|
206
|
+
if (prDelta.add.length || prDelta.remove.length) await gh.setPrLabels(pr.number, prDelta);
|
|
207
|
+
}
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
export async function mirrorLabels({ gh, issue, pr }) {
|
package/src/workerLogs.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Durable per-attempt worker logs.
|
|
2
2
|
//
|
|
3
|
-
// A tmux worker's output
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// surface a bounded, redacted tail when it later notices the worker is gone.
|
|
3
|
+
// A tmux worker's pane output disappears with its window. Every launch reserves
|
|
4
|
+
// a file here and streams its combined output into it, preserving evidence the
|
|
5
|
+
// supervisor can surface as a bounded, redacted tail after the worker is gone.
|
|
7
6
|
//
|
|
8
7
|
// Everything is best effort: a log that cannot be written must never cost a
|
|
9
8
|
// worker, so every operation degrades to `null` rather than throwing.
|
|
@@ -26,7 +25,7 @@ export const TAIL_CHARS = 4000;
|
|
|
26
25
|
// Roles are an allowlist, not a passthrough: a later repair worker adds one
|
|
27
26
|
// string here and needs no other change, while no caller-supplied value can
|
|
28
27
|
// ever reach the path.
|
|
29
|
-
export const WORKER_ROLES = new Set(["issue", "review"]);
|
|
28
|
+
export const WORKER_ROLES = new Set(["issue", "review", "repair"]);
|
|
30
29
|
|
|
31
30
|
const REDACTIONS = [
|
|
32
31
|
// Whole PEM blocks first, before any inner rule can fragment them. The body
|
|
@@ -154,9 +153,10 @@ export function createWorkerLogs({
|
|
|
154
153
|
return join(dir, `${id.role}-${id.number}.${attempt}.log`);
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
// The newest recorded attempt for a worker, with its
|
|
158
|
-
// redacted. `null` when there is nothing to show —
|
|
159
|
-
// failure, because diagnostics must never break the
|
|
156
|
+
// The newest recorded attempt for a worker, with its attempt number and its
|
|
157
|
+
// tail already bounded and redacted. `null` when there is nothing to show —
|
|
158
|
+
// including on any read failure, because diagnostics must never break the
|
|
159
|
+
// poll that surfaces them.
|
|
160
160
|
async function diagnostics(role, number) {
|
|
161
161
|
const id = identity(role, number);
|
|
162
162
|
if (!id) return null;
|
|
@@ -164,8 +164,12 @@ export function createWorkerLogs({
|
|
|
164
164
|
const dir = await logDir();
|
|
165
165
|
const attempts = await existingAttempts(dir, id.role, id.number);
|
|
166
166
|
if (attempts.length === 0) return null;
|
|
167
|
-
const
|
|
168
|
-
|
|
167
|
+
const newest = attempts.at(-1);
|
|
168
|
+
const path = join(dir, newest.name);
|
|
169
|
+
// The attempt number is this worker's durable launch count: retention
|
|
170
|
+
// prunes the *oldest* files while `prepare` numbers from the highest
|
|
171
|
+
// surviving one, so it keeps rising past `KEEP_ATTEMPTS`.
|
|
172
|
+
return { path, attempt: newest.attempt, tail: boundedTail(await readFile(path, "utf8")) };
|
|
169
173
|
} catch {
|
|
170
174
|
return null;
|
|
171
175
|
}
|