@plainconceptsplatform/workflows 0.16.4 → 0.17.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.
|
@@ -26,6 +26,12 @@ export function mandatoryFileSpecs(sourcePath) {
|
|
|
26
26
|
export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
27
27
|
return resolve(dirname(modulePath), "..", "loops");
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Where `gh aw` writes the pinned-action lock, and the only place it should be named.
|
|
31
|
+
* `generatedConsumerTargets` in the catalog declares the same path; two literals is how the
|
|
32
|
+
* pre-commit hook ended up guarding one that exists nowhere.
|
|
33
|
+
*/
|
|
34
|
+
const ACTIONS_LOCK = ".github/aw/actions-lock.json";
|
|
29
35
|
const isWorker = (target) => target.startsWith(".github/workflows/agent-") && target.endsWith(".md");
|
|
30
36
|
const isRouter = (target) => target === ".github/workflows/work-router.yml";
|
|
31
37
|
const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs)$/.test(target);
|
|
@@ -287,7 +293,13 @@ async function preCommitHookUpdate(repositoryPath) {
|
|
|
287
293
|
const hookPath = join(repositoryPath, target);
|
|
288
294
|
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
289
295
|
const stageLine = "git add -- .github/workflows/*.lock.yml";
|
|
290
|
-
|
|
296
|
+
// gh aw writes this at .github/aw/, which is what every consumer tracks and what
|
|
297
|
+
// `generatedConsumerTargets` in the catalog declares. The line used to name
|
|
298
|
+
// `.github/actions/actions-lock.json`, a path that exists nowhere, so `[ ! -f ... ]` was
|
|
299
|
+
// always true, the `||` short-circuited, and the real lock was never staged: a compile that
|
|
300
|
+
// bumped an action pin left the lock out of the commit and the tree dirty behind it. Nothing
|
|
301
|
+
// failed, which is why it survived. Found by the first audit run in the dogfood repository.
|
|
302
|
+
const actionLockLine = `[ ! -f ${ACTIONS_LOCK} ] || git add -- ${ACTIONS_LOCK}`;
|
|
291
303
|
const managedLines = `if git diff --cached --name-only -- .github | grep -q .; then\n ${compileLine}\n ${stageLine}\n ${actionLockLine}\nfi\n`;
|
|
292
304
|
if (!await exists(hookPath)) {
|
|
293
305
|
return { target, content: managedLines };
|
|
@@ -363,9 +375,9 @@ async function generatedFiles(repositoryPath) {
|
|
|
363
375
|
updates.push({ target: `.github/workflows/${file.replaceAll("\\", "/")}`, content: await readFile(join(workflowPath, file), "utf8") });
|
|
364
376
|
}
|
|
365
377
|
}
|
|
366
|
-
const actionsLock = join(repositoryPath,
|
|
378
|
+
const actionsLock = join(repositoryPath, ...ACTIONS_LOCK.split("/"));
|
|
367
379
|
if (await exists(actionsLock)) {
|
|
368
|
-
updates.push({ target:
|
|
380
|
+
updates.push({ target: ACTIONS_LOCK, content: await readFile(actionsLock, "utf8") });
|
|
369
381
|
}
|
|
370
382
|
return updates.sort((left, right) => left.target.localeCompare(right.target));
|
|
371
383
|
}
|
|
@@ -75,6 +75,13 @@ runs:
|
|
|
75
75
|
const names = (issue) => issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
|
|
76
76
|
|
|
77
77
|
const needsHuman = [];
|
|
78
|
+
// Stalled work still inside its retry window. Separate from needsHuman because nobody
|
|
79
|
+
// needs to act on it yet, and reported anyway because the alternative is silence: the
|
|
80
|
+
// window is 6 hours and the budget is 3 attempts, so an issue that keeps stalling was
|
|
81
|
+
// invisible for eighteen hours before it surfaced as retries-exhausted. Watched that
|
|
82
|
+
// happen to an audit report in the dogfood repository -- parked, and the digest said
|
|
83
|
+
// one item while three issues were sitting there.
|
|
84
|
+
const retrying = [];
|
|
78
85
|
let retried = 0;
|
|
79
86
|
|
|
80
87
|
// 1. Retry what the machine stalled on. `stalled` marks a park caused by a crash, a
|
|
@@ -96,7 +103,10 @@ runs:
|
|
|
96
103
|
needsHuman.push({ kind: 'decided', issue, labels, waited });
|
|
97
104
|
continue;
|
|
98
105
|
}
|
|
99
|
-
if (waited < retryAfterMs)
|
|
106
|
+
if (waited < retryAfterMs) {
|
|
107
|
+
retrying.push({ issue, waited, attempts, work, due: retryAfterMs - waited });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
100
110
|
if (attempts >= maxRetries || !work) {
|
|
101
111
|
needsHuman.push({ kind: attempts >= maxRetries ? 'retries-exhausted' : 'no-work-label', issue, labels, waited, attempts });
|
|
102
112
|
continue;
|
|
@@ -203,39 +213,64 @@ runs:
|
|
|
203
213
|
|
|
204
214
|
// 6. One digest. Everything above either fixed itself or is listed here, so there is a
|
|
205
215
|
// single place to look instead of four repositories and three label queries.
|
|
216
|
+
// Audit reports nobody actioned. audit-close labels these `stale-audit` once they are
|
|
217
|
+
// past their budget, which frees the audit's backpressure slot. Freeing the slot
|
|
218
|
+
// without listing the report anywhere would only move the silence, so they land here.
|
|
219
|
+
for (const issue of issues) {
|
|
220
|
+
if (issue.pull_request) continue;
|
|
221
|
+
if (!names(issue).includes('stale-audit')) continue;
|
|
222
|
+
needsHuman.push({ kind: 'stale-audit', issue, waited: age(issue.created_at) });
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
const line = (entry) => {
|
|
207
226
|
if (entry.kind === 'pr-parked') return `- PR #${entry.pull.number} · parked ${days(entry.waited)}d · gate verdict \`${entry.verdict}\` · ${entry.pull.title}`;
|
|
208
227
|
if (entry.kind === 'pr-stale') return `- PR #${entry.pull.number} · ${days(entry.waited)}d old · no gate verdict yet · ${entry.pull.title}`;
|
|
209
228
|
if (entry.kind === 'retries-exhausted') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · retried ${entry.attempts}× and still stalling · ${entry.issue.title}`;
|
|
210
229
|
if (entry.kind === 'no-work-label') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · no work label to retry with · ${entry.issue.title}`;
|
|
230
|
+
if (entry.kind === 'stale-audit') return `- #${entry.issue.number} · open ${days(entry.waited)}d · audit report nobody actioned · ${entry.issue.title}`;
|
|
211
231
|
return `- #${entry.issue.number} · waiting ${days(entry.waited)}d · ${entry.labels.filter((l) => l !== 'review').join(', ') || 'no other labels'} · ${entry.issue.title}`;
|
|
212
232
|
};
|
|
213
233
|
const section = (heading, kinds) => {
|
|
214
234
|
const rows = needsHuman.filter((e) => kinds.includes(e.kind));
|
|
215
235
|
return rows.length === 0 ? '' : `\n### ${heading} (${rows.length})\n\n${rows.map(line).join('\n')}\n`;
|
|
216
236
|
};
|
|
237
|
+
// Listed, but not as something to act on: the machine is going to try again by itself.
|
|
238
|
+
// The heading says so, because a reader who acts on these duplicates the retry.
|
|
239
|
+
const retryingSection = () => {
|
|
240
|
+
if (retrying.length === 0) return '';
|
|
241
|
+
const hours = (ms) => Math.max(1, Math.round(ms / 3600000));
|
|
242
|
+
const rows = retrying.map((entry) =>
|
|
243
|
+
`- #${entry.issue.number} · stalled ${hours(entry.waited)}h ago · attempt ${entry.attempts + 1} of ${maxRetries} due in ~${hours(entry.due)}h · ${entry.issue.title}`);
|
|
244
|
+
return `\n### Retrying by itself, no action needed (${retrying.length})\n\n${rows.join('\n')}\n`;
|
|
245
|
+
};
|
|
217
246
|
const body = [
|
|
218
247
|
DIGEST_MARKER,
|
|
219
248
|
`_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC. This issue is rewritten by the housekeeping job; edits to it are lost._`,
|
|
220
249
|
section('Waiting on a decision', ['decided']),
|
|
221
250
|
section('Pull requests parked by the gate', ['pr-parked', 'pr-stale']),
|
|
222
251
|
section('Stalled beyond the retry budget', ['retries-exhausted', 'no-work-label']),
|
|
252
|
+
section('Audit reports nobody actioned', ['stale-audit']),
|
|
253
|
+
retryingSection(),
|
|
223
254
|
needsHuman.length === 0 ? '\nNothing is waiting on a person right now.\n' : '',
|
|
224
255
|
`\n---\n\nThis run retried ${retried} stalled issue(s) and made ${planned.length} change(s) in total.`,
|
|
225
256
|
].filter(Boolean).join('\n');
|
|
226
257
|
|
|
227
258
|
if (digestTitle) {
|
|
259
|
+
// The digest exists whenever there is anything to show, including work the machine is
|
|
260
|
+
// about to retry. Gating it on needsHuman alone is what left three parked issues
|
|
261
|
+
// with a digest that named one.
|
|
262
|
+
const anythingToShow = needsHuman.length + retrying.length;
|
|
228
263
|
const existing = issues.find((i) => !i.pull_request && i.title === digestTitle && (i.body ?? '').includes(DIGEST_MARKER));
|
|
229
264
|
if (existing) {
|
|
230
|
-
if (
|
|
265
|
+
if (anythingToShow === 0) {
|
|
231
266
|
await act(`closing the digest #${existing.number}: nothing is waiting`, () =>
|
|
232
267
|
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body, state: 'closed', state_reason: 'completed' }));
|
|
233
268
|
} else {
|
|
234
|
-
await act(`updating the digest #${existing.number}: ${
|
|
269
|
+
await act(`updating the digest #${existing.number}: ${anythingToShow} item(s)`, () =>
|
|
235
270
|
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }));
|
|
236
271
|
}
|
|
237
|
-
} else if (
|
|
238
|
-
await act(`opening the digest: ${
|
|
272
|
+
} else if (anythingToShow > 0) {
|
|
273
|
+
await act(`opening the digest: ${anythingToShow} item(s)`, () =>
|
|
239
274
|
github.rest.issues.create({ ...context.repo, title: digestTitle, body }));
|
|
240
275
|
}
|
|
241
276
|
}
|
|
@@ -245,6 +280,7 @@ runs:
|
|
|
245
280
|
.addList([
|
|
246
281
|
`Retried: ${retried}`,
|
|
247
282
|
`Waiting on a person: ${needsHuman.length}`,
|
|
283
|
+
`Stalled, retrying by itself: ${retrying.length}`,
|
|
248
284
|
`Changes ${dryRun ? 'planned' : 'made'}: ${planned.length}`,
|
|
249
285
|
])
|
|
250
286
|
.addRaw(planned.length ? `\n\n${planned.map((p) => `- ${p}`).join('\n')}` : '')
|