@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
- const actionLockLine = "[ ! -f .github/actions/actions-lock.json ] || git add -- .github/actions/actions-lock.json";
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, ".github", "actions", "actions-lock.json");
378
+ const actionsLock = join(repositoryPath, ...ACTIONS_LOCK.split("/"));
367
379
  if (await exists(actionsLock)) {
368
- updates.push({ target: ".github/actions/actions-lock.json", content: await readFile(actionsLock, "utf8") });
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) continue;
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 (needsHuman.length === 0) {
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}: ${needsHuman.length} item(s)`, () =>
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 (needsHuman.length > 0) {
238
- await act(`opening the digest: ${needsHuman.length} item(s)`, () =>
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')}` : '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",