omp-conductor 0.15.9 → 0.15.11

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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/types.ts CHANGED
@@ -250,6 +250,14 @@ export const RELEASE_SHAPES = [
250
250
  "package-publish",
251
251
  "github-release",
252
252
  "deploy",
253
+ /**
254
+ * Replace the running conductor itself: the Bun-global CLI, the omp plugin
255
+ * and the Herdr plugin, pinned to one published release and executed
256
+ * detached. This is the "nobody patches the running conductor" boundary
257
+ * moved deliberately, not worked around — a session that may cut a release
258
+ * still cannot install one until an operator grants this shape too.
259
+ */
260
+ "install",
253
261
  ] as const;
254
262
 
255
263
  export type ReleaseShape = (typeof RELEASE_SHAPES)[number];
@@ -293,6 +301,7 @@ export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
293
301
  "package-publish": "human",
294
302
  "github-release": "human",
295
303
  deploy: "human",
304
+ install: "human",
296
305
  };
297
306
 
298
307
  /**
@@ -308,6 +317,9 @@ export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = {
308
317
  "package-publish": "orchestrator",
309
318
  "github-release": "orchestrator",
310
319
  deploy: "human",
320
+ // Nobody expected a legacy "operator-brief" grant to cover patching the
321
+ // running conductor; like `deploy` it stays with the human.
322
+ install: "human",
311
323
  };
312
324
 
313
325
  /**
@@ -335,11 +347,16 @@ export const AUTHORITY_HOLDERS = ["human", "orchestrator"] as const;
335
347
  export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
336
348
 
337
349
  /**
338
- * What a project that never answered the question gets: humans keep both. A
339
- * default that delegated merging would hand a fresh fleet write access to its
340
- * own main branch on the strength of an unread config file.
350
+ * What a project that never answered the question gets: humans keep all three.
351
+ * A default that delegated merging would hand a fresh fleet write access to
352
+ * its own main branch on the strength of an unread config file, and a default
353
+ * that delegated promotion would let the orchestrator start spend unasked.
341
354
  */
342
- export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
355
+ export const DEFAULT_AUTHORITY: ProjectConfig["authority"] & { promotion: AuthorityHolder } = {
356
+ merge: "human",
357
+ release: "human",
358
+ promotion: "human",
359
+ };
343
360
 
344
361
  /**
345
362
  * Whether a merge candidate must be up to date with the branch it targets.
@@ -621,12 +638,17 @@ export interface ProjectConfig {
621
638
  */
622
639
  escalation: { telegramChatId?: string; telegramTopicId?: number; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
623
640
  /**
624
- * Who lands green PRs and who cuts releases. The daemon never acts on this
625
- * itself — it words the orchestrator's standing orders and the rendered brief
626
- * scaffold with it, so config and prompt can never disagree about which of
627
- * them is holding the merge button.
641
+ * Who lands green PRs, who cuts releases and who promotes. The daemon never
642
+ * acts on this itself — it words the orchestrator's standing orders and the
643
+ * rendered brief scaffold with it, so config and prompt can never disagree
644
+ * about which of them is holding the merge button.
645
+ *
646
+ * `promotion` is the queue-label sign-off: adding the label to an issue lets
647
+ * a worker claim it and starts spend, so POLICY.md must state its owner and
648
+ * a config that never answered defaults to the human — a gate whose owner is
649
+ * unstated must never resolve to the orchestrator.
628
650
  */
629
- authority: { merge: AuthorityHolder; release: AuthorityHolder };
651
+ authority: { merge: AuthorityHolder; release: AuthorityHolder; promotion?: AuthorityHolder };
630
652
  /**
631
653
  * Per-shape tool-call enforcement for releases and deploys. Optional only for
632
654
  * configs written before the tripwire existed; both omission and any shape key
@@ -755,17 +777,10 @@ export interface PrDiff {
755
777
  * vocabulary that lives in three places drifts in three directions.
756
778
  */
757
779
  export const SETTLEMENT_FLAG_KINDS = [
758
- /** The PR touched a file the report's `changed:` line never mentioned — the
759
- * direction that matters, an undisclosed edit. */
760
- "undisclosed-file",
761
- /** The report carried no usable `changed:` line, so nothing was disclosed. */
780
+ /** The settlement could not read the PR's diff, so no `changed:` file list
781
+ * could be derived — the one disclosure fault that survives, because a
782
+ * tree no one can read has nothing to disclose. */
762
783
  "changed-line-missing",
763
- /** `changed:` named a path the PR never touched. The weaker direction. */
764
- "unmatched-claim",
765
- /** The same file appears as both claimed-but-untouched and
766
- * touched-but-unclaimed — the `changed:` line's format defeated the parser;
767
- * read the diff directly. */
768
- "report-format-unparsed",
769
784
  /** A test file left the tree and no rename in the PR accounts for it. */
770
785
  "test-file-deleted",
771
786
  /** A skip/only/focus marker appears on a line the PR added. */
@@ -944,8 +959,13 @@ export interface Tracker {
944
959
  * Verify that a pull request is open, ready, and has a non-empty
945
960
  * terminal-success check rollup. When `expectedHead` is supplied, also
946
961
  * require the live head to match it.
962
+ *
963
+ * `opts.read` switches from the merge gate's "may this merge" to the read
964
+ * surface's "what is this PR's state": a merged or closed PR is then a
965
+ * reported fact rather than an `expected OPEN` refusal. Mutating verbs never
966
+ * pass `read`, so their open-PR gate is unchanged.
947
967
  */
948
- verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined>;
968
+ verifyPr(url: string, expectedHead?: string, opts?: { read?: boolean }): Promise<PrVerification | undefined>;
949
969
  /**
950
970
  * The pull request's diff, or undefined when this adapter could not produce
951
971
  * one — an unparseable URL, a deleted PR, a flaky network.
@@ -1010,6 +1030,8 @@ export const FAILURE_CLASSES = [
1010
1030
  "env-start-failure",
1011
1031
  "turn-cap-progress",
1012
1032
  "turn-cap-spinning",
1033
+ "wall-clock-cap-progress",
1034
+ "wall-clock-cap-spinning",
1013
1035
  "admin-kill",
1014
1036
  "ci-infra",
1015
1037
  "ci-deterministic",
@@ -1144,10 +1166,10 @@ export interface RunRecord {
1144
1166
  * exactly as an unflagged one does, and the flags are evidence for whoever
1145
1167
  * reviews the PR. Absent means the audit found nothing, or never ran. */
1146
1168
  settlementFlags?: SettlementFlag[];
1147
- /** The worker's settlement report text for this attempt, verbatim. Persisted
1148
- * on every terminal update so the next attempt can pool its disclosures
1149
- * (#199). Absent means the run predates the column, or was killed before the
1150
- * worker returned a report. */
1169
+ /** The worker's settlement report text for this attempt: the worker's own
1170
+ * narrative, with the `changed:` file list rewritten at settlement from
1171
+ * the PR's actual diff (#488). Absent means the run predates the column, or
1172
+ * was killed before the worker returned a report. */
1151
1173
  report?: string;
1152
1174
  /**
1153
1175
  * Why this run ended badly, and what the daemon did about it (#132). Absent
@@ -1162,6 +1184,15 @@ export interface RunRecord {
1162
1184
  recoveredAt?: number;
1163
1185
  }
1164
1186
 
1187
+ /**
1188
+ * A partial patch for {@link updateRun}. Every key is optional; an absent key
1189
+ * and an explicitly-`undefined` value both leave the column alone, while an
1190
+ * explicit `null` clears it (#468). `null` exists only for the columns a
1191
+ * caller may legitimately empty — a settle can never widen into a column it
1192
+ * did not mean to write.
1193
+ */
1194
+ export type RunPatch = { [K in keyof RunRecord]?: RunRecord[K] | null };
1195
+
1165
1196
  /** A one-shot turn budget waiting for one issue's next claimed attempt. */
1166
1197
  export interface TurnOverride {
1167
1198
  project: string;
@@ -1211,6 +1242,13 @@ export interface DispatchSummary {
1211
1242
  /** True only for system/API failures, never ordinary policy holds. */
1212
1243
  degraded: boolean;
1213
1244
  holds: AdmissionHoldSummary[];
1245
+ /** Runs the pass settled while it ran — merged PRs terminalised by the settle
1246
+ * sweep and by classification recovery. Optional: persisted old rows lack
1247
+ * it, so readers use `?? 0`. (#497) */
1248
+ settled?: number;
1249
+ /** True for a held pass's own record (#497): the queue was never routed, so
1250
+ * ready/routed/claimed are absent queue facts, not an empty queue. */
1251
+ paused?: boolean;
1214
1252
  }
1215
1253
 
1216
1254
  /** Admission holds that indicate repairable friction rather than normal flow control. */
@@ -1261,11 +1299,22 @@ export interface FrictionSignal {
1261
1299
  * today's digest already been handed over?" without asking the model to
1262
1300
  * remember, which is the memory #123 found unreliable.
1263
1301
  *
1302
+ * The remaining values are the interrupt categories themselves. Handing a
1303
+ * report over as `fleet-stopped` or `tier2` (or `decision-needed`,
1304
+ * `confirmed-failure`) is the escalation handoff: the project's reporting
1305
+ * policy decides between sending now and holding, exactly as it does for a
1306
+ * daemon escalation of that category, and an identical retry is refused only
1307
+ * while the earlier handoff is still undelivered (#453).
1308
+ *
1264
1309
  * Declared as data for the same reason as {@link REPORT_SCOPES}: the CLI verb
1265
1310
  * that accepts a kind and the outbox that renders one enumerate the same list,
1266
1311
  * so a third kind cannot be added while either still knows only two.
1267
1312
  */
1268
- export const REPORT_KINDS = ["material", "digest"] as const;
1313
+ export const REPORT_KINDS = [
1314
+ "material",
1315
+ "digest",
1316
+ ...INTERRUPT_CATEGORIES.filter((category) => category !== "material"),
1317
+ ] as const;
1269
1318
 
1270
1319
  export type ReportKind = (typeof REPORT_KINDS)[number];
1271
1320
 
@@ -1377,7 +1426,41 @@ export interface DigestBacklog {
1377
1426
  heldNoticeCount: number;
1378
1427
  /** Subset held only for the next availability window. */
1379
1428
  availabilityHeldNoticeCount?: number;
1429
+ /** Oldest held notice across both held groups. */
1380
1430
  heldNoticeOldestAt?: number;
1431
+ /** Oldest held notice that is digest-bound (`releaseOnAvailable` false). */
1432
+ digestHeldNoticeOldestAt?: number;
1433
+ /** Oldest held notice waiting only for the availability window. */
1434
+ availabilityHeldNoticeOldestAt?: number;
1435
+ }
1436
+
1437
+ /** Where one captured raw idea is in its lifecycle (#298). `pending` is a raw
1438
+ * idea awaiting grooming; `groomed` became an issue (#300, which records the
1439
+ * issue URL); `dismissed` was dropped without becoming anything. */
1440
+ export const INTAKE_STATES = ["pending", "groomed", "dismissed"] as const;
1441
+
1442
+ export type IntakeState = (typeof INTAKE_STATES)[number];
1443
+
1444
+ /** One raw idea captured by `omp-conductor intake`, durable in the store so an
1445
+ * idea from 02:00 is still there after a restart (#299). */
1446
+ export interface IntakeItem {
1447
+ id: string;
1448
+ project: string;
1449
+ /** The idea text verbatim, as captured. */
1450
+ text: string;
1451
+ createdAt: number;
1452
+ state: IntakeState;
1453
+ /** The issue this idea became, once #300 grooms it. */
1454
+ issueUrl?: string;
1455
+ /** When the item was resolved into an issue (#300) or dismissed. */
1456
+ groomedAt?: number;
1457
+ }
1458
+
1459
+ /** What a caller hands over. The store owns the id, the state and the timestamps. */
1460
+ export interface IntakeDraft {
1461
+ project: string;
1462
+ text: string;
1463
+ at: number;
1381
1464
  }
1382
1465
 
1383
1466
  /**
@@ -1460,7 +1543,8 @@ export interface LabelOp {
1460
1543
  export interface Store {
1461
1544
  /** A claimed run atomically consumes and applies its issue's pending turn override. */
1462
1545
  createRun(r: Omit<RunRecord, "id">): RunRecord;
1463
- updateRun(id: string, patch: Partial<RunRecord>): void;
1546
+ /** Partial patch; an explicit `null` clears a column, `undefined`/absence leaves it alone (#468). */
1547
+ updateRun(id: string, patch: RunPatch): void;
1464
1548
  getRun(id: string): RunRecord | undefined;
1465
1549
  /** Runs whose issue is occupied: a live worker, or a green PR awaiting merge. */
1466
1550
  activeRuns(project: string): RunRecord[];
@@ -1471,6 +1555,11 @@ export interface Store {
1471
1555
  /** Newest attempt per issue for the live board. Non-merged work remains
1472
1556
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
1473
1557
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1558
+ /** Every attempt that recorded one PR URL, newest first. Mediated PR verbs
1559
+ * resolve ownership through this, not through `recentRuns`' newest-attempt-
1560
+ * per-issue view — a requeued continuation must never hide the PR its
1561
+ * predecessor opened. Merged rows keep the same recent-history bound. */
1562
+ runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[];
1474
1563
  /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
1475
1564
  runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
1476
1565
  /** Replace the current live-head health row for one routed repository. */
@@ -1500,6 +1589,9 @@ export interface Store {
1500
1589
  * tail` resolves an issue number to a transcript through this; the number is
1501
1590
  * what an operator has, the run id is not. */
1502
1591
  latestRun(project: string, issue: number): RunRecord | undefined;
1592
+ /** Every attempt for one issue, oldest first, so an escalation can group the
1593
+ * continuation budget by failure class (#439). */
1594
+ runsForIssue(project: string, issue: number): RunRecord[];
1503
1595
  /** Persist a one-shot ceiling and append the operator action to its audit ledger. */
1504
1596
  setTurnOverride(project: string, issue: number, maxTurns: number, setAt?: number): void;
1505
1597
  turnOverride(project: string, issue: number): number | undefined;
@@ -1511,16 +1603,24 @@ export interface Store {
1511
1603
  project: string,
1512
1604
  opts?: { issue?: number; limit?: number },
1513
1605
  ): TurnOverride[];
1514
- /** Every attempt of one issue that settled with a report, in attempt order.
1515
- * The settlement audit pools these as prior disclosures when a later
1516
- * attempt is reconciled (#199). Rows with no report (pre-#199, or killed
1517
- * before the worker returned) are skipped. */
1518
- attemptReports(project: string, issue: number): { attempt: number; report: string }[];
1519
1606
  runsStartedSince(project: string, sinceEpochMs: number): number;
1520
1607
  spendSince(project: string, sinceEpochMs: number): number;
1608
+ /** The rows one stats window needs (#282): every run settling at or after
1609
+ * `sinceEpochMs`, plus the full attempt chain of each issue whose merge
1610
+ * settled there (so continuation chains collapse into one journey), ordered
1611
+ * by issue then startedAt. Read-only. */
1612
+ statsRuns(project: string, sinceEpochMs: number): RunRecord[];
1613
+ /** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */
1614
+ ghCallsBetween(sinceDay: string, untilDay: string): number;
1521
1615
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
1522
1616
  * same event. */
1523
1617
  wasNotified(key: string): boolean;
1618
+ /** Whether an escalation handoff with this project, category and folded text
1619
+ * is still owed: a same-text report still in the outbox, or a same-text
1620
+ * held notice not yet digested. The CLI report surface refuses an identical
1621
+ * retry only while this is true (#453) — once the first handoff lands, the
1622
+ * same text is a new occurrence, not a double-send. */
1623
+ hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean;
1524
1624
  recordDispatch(project: string, summary: DispatchSummary): void;
1525
1625
  latestDispatch(project: string): DispatchSummary | undefined;
1526
1626
  /** The newest `digest:` dedupe key a project has run toward without ending in
@@ -1544,6 +1644,13 @@ export interface Store {
1544
1644
  getMaterialEvent(id: string): MaterialEvent | undefined;
1545
1645
  /** Ordinary material outcomes still owed, oldest first and bounded. */
1546
1646
  undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
1647
+ /** Persist one raw idea, `pending`, for later grooming (#299). */
1648
+ recordIntake(draft: IntakeDraft): IntakeItem;
1649
+ /** Ideas still `pending`, oldest first — what `intake list` and `status` read. */
1650
+ pendingIntake(project: string): IntakeItem[];
1651
+ /** Resolve one idea to `groomed` (recording the issue URL #300 chose) or
1652
+ * `dismissed`. `false` when the id is unknown. */
1653
+ resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean;
1547
1654
  /** Count and age source for status and digest prompt bounds. */
1548
1655
  digestBacklog(project: string): DigestBacklog;
1549
1656
  /** Add one bounded observation to the per-day friction rollup. */
@@ -1655,6 +1762,8 @@ export interface Store {
1655
1762
  createDecision(draft: DecisionDraft): DecisionRecord;
1656
1763
  /** Everything still owed an answer, oldest first — what a tick digest reads. */
1657
1764
  openDecisions(project: string): DecisionRecord[];
1765
+ /** One row by id, whatever its state — what a bounded ask polls while waiting. */
1766
+ decision(id: string): DecisionRecord | undefined;
1658
1767
  /** Answer or withdraw one. `false` when the id is unknown or already closed,
1659
1768
  * so a double-resolve cannot overwrite the first answer. */
1660
1769
  resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean;
@@ -1805,6 +1914,10 @@ export const VERB_NAMES = [
1805
1914
  "conductor_pr_merge",
1806
1915
  "conductor_label",
1807
1916
  "conductor_release",
1917
+ /** Request the fleet upgrade itself to a published conductor release. The
1918
+ * install shape gates it like a release shape; execution detaches from this
1919
+ * session and the daemon, and the first tick after the restart verifies. */
1920
+ "conductor_install",
1808
1921
  /** The one read verb. It answers with the merge gate's own verdict, so
1809
1922
  * "pushed-green" is the dispatcher's reading of the PR rather than a claim the
1810
1923
  * worker makes about itself from whatever it happened to run. */
package/src/unblock.ts CHANGED
@@ -35,6 +35,7 @@
35
35
  * failed-attempt budget.
36
36
  */
37
37
 
38
+ import { hasContinuationBudget, hasFailedAttemptBudget } from "./daemon.ts";
38
39
  import { projectLabels } from "./label-projection.ts";
39
40
  import { LIVE_STATES } from "./store.ts";
40
41
  import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
@@ -64,11 +65,18 @@ export interface UnblockOutcome {
64
65
  /** Set when `--force` recorded an operator's acceptance of that loss. */
65
66
  forced?: true;
66
67
  /** Set when the queue label was re-added (the default); absent on
67
- * --no-requeue, on the refusal path, and when a live worker (claimed or
68
- * running) is still on the issue. A pushed-green/pushed-pending occupancy
68
+ * --no-requeue, on the refusal path, when a live worker (claimed or
69
+ * running) is still on the issue, and when a spent budget withheld the
70
+ * re-queue (requeueWithheld). A pushed-green/pushed-pending occupancy
69
71
  * is worker-free, so the label is still restored there (#175 bypasses it;
70
72
  * `isEligible` needs the label once the PR resolves). */
71
73
  requeued?: true;
74
+ /** Set when the re-queue was withheld because the issue's failed-attempt or
75
+ * continuation budget is spent — the same admission gate the dispatcher
76
+ * holds on (failed-attempts / continuations), so the queue label would
77
+ * manufacture a candidate that can never be admitted (#348). The lifecycle
78
+ * clears still happened; only the add was skipped. */
79
+ requeueWithheld?: true;
72
80
  /** Set when `--no-requeue` skipped the queue-label re-add. */
73
81
  requeueSkipped?: true;
74
82
  /** Set when the tracker refused one or more of this verb's label ops, so
@@ -121,6 +129,7 @@ export async function unblockIssue(
121
129
  store: Store,
122
130
  issue: number,
123
131
  opts: { force?: boolean; requeue?: boolean } = {},
132
+ caps: Caps,
124
133
  ): Promise<UnblockOutcome> {
125
134
  const requeue = opts.requeue !== false;
126
135
  // Read before any label is touched: terminality is the whole of the argument
@@ -145,6 +154,16 @@ export async function unblockIssue(
145
154
  active,
146
155
  };
147
156
 
157
+ // The dispatcher's own admission gates, as daemon.ts computes them (#348):
158
+ // every failed-implementation slot spent, or the continuation budget
159
+ // exceeded, and the issue is held (`failed-attempts` / `continuations`) on
160
+ // every subsequent tick forever. A re-queued issue in that state is exactly
161
+ // the permanent admission hold this verb must not manufacture, so the
162
+ // re-queue half below is decided on the same predicates that decide the hold.
163
+ const budgetSpent =
164
+ !hasFailedAttemptBudget(counts.failuresUsed, caps.maxAttemptsPerIssue) ||
165
+ !hasContinuationBudget(counts.continuationsUsed, caps.maxContinuationsPerIssue);
166
+
148
167
  // The one case where this verb refuses. Clearing the labels here re-queues an
149
168
  // issue whose next claim starts by force-removing the worktree that holds the
150
169
  // only copy of the last attempt's work (#118) — and unlike every other state
@@ -185,14 +204,22 @@ export async function unblockIssue(
185
204
  // its PR is live but no process writes to its branch — so a pushed
186
205
  // continuation still gets the label: the dispatcher bypasses worker-free
187
206
  // pushed-green rows (#175), and `isEligible` requires the queue label once
188
- // the PR resolves closed-unmerged.
207
+ // the PR resolves closed-unmerged. And never when the failed-attempt or
208
+ // continuation budget is spent (#348): the dispatcher holds such an issue
209
+ // forever, so the label would claim a candidacy the next admission cannot
210
+ // deliver — the clears still happen, the add is withheld.
189
211
  let requeued: true | undefined;
212
+ let requeueWithheld: true | undefined;
190
213
  let requeueSkipped: true | undefined;
191
214
  let labelSyncQueued: number | undefined;
192
215
  if (!live) {
193
216
  if (requeue) {
194
- ops.push({ issue, op: "add", label: project.queueLabel });
195
- requeued = true;
217
+ if (budgetSpent) {
218
+ requeueWithheld = true;
219
+ } else {
220
+ ops.push({ issue, op: "add", label: project.queueLabel });
221
+ requeued = true;
222
+ }
196
223
  } else {
197
224
  requeueSkipped = true;
198
225
  }
@@ -222,6 +249,7 @@ export async function unblockIssue(
222
249
  ...(latest === undefined ? {} : { latest }),
223
250
  ...(held === undefined ? {} : { forced: true as const }),
224
251
  ...(requeued === undefined ? {} : { requeued }),
252
+ ...(requeueWithheld === undefined ? {} : { requeueWithheld }),
225
253
  ...(requeueSkipped === undefined ? {} : { requeueSkipped }),
226
254
  ...(labelSyncQueued === undefined ? {} : { labelSyncQueued }),
227
255
  };
@@ -231,10 +259,14 @@ export async function unblockIssue(
231
259
  * What the operator reads back. It promises a re-claim only when one can
232
260
  * actually happen, because a promise made blindly sends someone away believing
233
261
  * work had resumed and they find out by waiting for it. Three things withhold
234
- * it: a live run still owns the issue through `agent:in-progress`, a spent
235
- * attempt budget makes the next tick escalate rather than dispatch, and — since
236
- * the in-progress label is only released for a terminal run — an issue with no
237
- * run row at all, where that label may still be sitting there unread. #18 was
262
+ * the re-claim promise: a live run still owns the issue through
263
+ * `agent:in-progress`, a spent attempt budget makes the next tick escalate
264
+ * rather than dispatch, and — since the in-progress label is only released for
265
+ * a terminal run — an issue with no run row at all, where that label may still
266
+ * be sitting there unread. A spent budget also withholds the queue label
267
+ * itself, on the same predicate the dispatcher holds on (#348): re-adding it
268
+ * would manufacture a candidate the next tick can never admit, and the report
269
+ * says so instead of claiming a restore that did not happen. #18 was
238
270
  * filed against this function saying `next tick eligible again` in a case where
239
271
  * it was not, so the wording is a contract rather than prose.
240
272
  *
@@ -297,16 +329,28 @@ export function formatUnblock(
297
329
  `"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
298
330
  ` queue "${project.queueLabel}" not restored — a run is still active; re-run unblock once it settles`,
299
331
  );
300
- } else if (o.failuresUsed >= caps.maxAttemptsPerIssue) {
332
+ } else if (!hasFailedAttemptBudget(o.failuresUsed, caps.maxAttemptsPerIssue)) {
301
333
  lines.push(
302
334
  ` next tick not eligible: all ${caps.maxAttemptsPerIssue} failed attempts are spent. ` +
303
335
  "Rewrite the issue or raise maxAttemptsPerIssue.",
304
336
  );
305
- } else if (o.continuationsUsed > caps.maxContinuationsPerIssue) {
337
+ if (o.requeueWithheld === true) {
338
+ lines.push(
339
+ ` queue "${project.queueLabel}" withheld — re-adding it would manufacture a candidate the ` +
340
+ "dispatcher holds as failed-attempts forever; rewrite the issue or raise maxAttemptsPerIssue before re-queueing",
341
+ );
342
+ }
343
+ } else if (!hasContinuationBudget(o.continuationsUsed, caps.maxContinuationsPerIssue)) {
306
344
  lines.push(
307
345
  ` next tick not eligible: the ${caps.maxContinuationsPerIssue}-continuation budget was exceeded. ` +
308
346
  "Inspect progress or raise maxContinuationsPerIssue.",
309
347
  );
348
+ if (o.requeueWithheld === true) {
349
+ lines.push(
350
+ ` queue "${project.queueLabel}" withheld — re-adding it would manufacture a candidate the ` +
351
+ "dispatcher holds as continuations forever; inspect progress or raise maxContinuationsPerIssue before re-queueing",
352
+ );
353
+ }
310
354
  } else if (latest === undefined) {
311
355
  lines.push(
312
356
  ` next tick eligible once the issue carries "${project.queueLabel}" and no state label — with no ` +