@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickysagan/issue-orchestrator",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A small, supervised GitHub issue queue that keeps autonomous coding workers alive.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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
+ }
@@ -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 is durable until Phase 7.
192
- async function setPhase({ gh, issue, pr, phase }) {
193
- const current = phaseOf(issue.labels);
194
- if (current === phase) return;
195
- const delta = { add: [phase], remove: current ? [current] : [] };
196
- await gh.setIssueLabels(issue.number, delta);
197
- if (pr) await gh.setPrLabels(pr.number, delta);
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 }) {
@@ -1,9 +1,8 @@
1
1
  // Durable per-attempt worker logs.
2
2
  //
3
- // A tmux worker's output lives and dies with its pane, so a window that
4
- // vanishes takes the only evidence of why with it. Every launch reserves a
5
- // file here and streams its combined output into it, which lets the supervisor
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 tail already bounded and
158
- // redacted. `null` when there is nothing to show — including on any read
159
- // failure, because diagnostics must never break the poll that surfaces them.
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 path = join(dir, attempts.at(-1).name);
168
- return { path, tail: boundedTail(await readFile(path, "utf8")) };
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
  }