omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -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 +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  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 +150 -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 +100 -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 +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. 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
  /**
@@ -324,6 +336,16 @@ export const SESSION_ROLES = ["worker", "orchestrator"] as const;
324
336
 
325
337
  export type SessionRole = (typeof SESSION_ROLES)[number];
326
338
 
339
+ /**
340
+ * Env var the daemon sets on a spawned session so the reporting CLI can tell a
341
+ * worker session from the operator's shell. The daemon stamps it on the
342
+ * session-host child (and thus on every tool process it spawns); a direct CLI
343
+ * run — the operator's shell or a systemd unit — has it absent and is treated
344
+ * as the orchestrator, preserving the historical surface. {@link SESSION_ROLES} is
345
+ * the closed vocabulary: a value outside it must be refused, never assumed.
346
+ */
347
+ export const SESSION_ROLE_ENV = "OMP_CONDUCTOR_SESSION_ROLE";
348
+
327
349
  /**
328
350
  * Who holds an authority the daemon itself never exercises. Declared as data
329
351
  * for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and
@@ -335,11 +357,16 @@ export const AUTHORITY_HOLDERS = ["human", "orchestrator"] as const;
335
357
  export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
336
358
 
337
359
  /**
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.
360
+ * What a project that never answered the question gets: humans keep all three.
361
+ * A default that delegated merging would hand a fresh fleet write access to
362
+ * its own main branch on the strength of an unread config file, and a default
363
+ * that delegated promotion would let the orchestrator start spend unasked.
341
364
  */
342
- export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
365
+ export const DEFAULT_AUTHORITY: ProjectConfig["authority"] & { promotion: AuthorityHolder } = {
366
+ merge: "human",
367
+ release: "human",
368
+ promotion: "human",
369
+ };
343
370
 
344
371
  /**
345
372
  * Whether a merge candidate must be up to date with the branch it targets.
@@ -615,18 +642,38 @@ export interface ProjectConfig {
615
642
  * answered the question wants.
616
643
  */
617
644
  workerModel?: string;
645
+ /**
646
+ * Ordered fallback models for this project, tried after {@link workerModel}
647
+ * once a run chain has suffered enough consecutive provider-class failures
648
+ * (stream stalls and credit refusals). Each new issue still starts on the
649
+ * primary; recovery is sticky to the chain, never global (#286). Empty or
650
+ * absent preserves the pre-failover dispatch exactly: every attempt stays on
651
+ * the primary until the existing escalation path takes over.
652
+ */
653
+ modelFallbacks?: string[];
654
+ /**
655
+ * Consecutive provider-class failures on one run chain after which the next
656
+ * dispatch moves to the next model in {@link modelFallbacks}. Defaults to 2
657
+ * when absent or unusable.
658
+ */
659
+ modelFallbackThreshold?: number;
618
660
  /**
619
661
  * How a stuck run reaches a human, what to do when it cannot, and who runs
620
662
  * the session that triages it. See {@link ORCHESTRATOR_MODES}.
621
663
  */
622
664
  escalation: { telegramChatId?: string; telegramTopicId?: number; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
623
665
  /**
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.
666
+ * Who lands green PRs, who cuts releases and who promotes. The daemon never
667
+ * acts on this itself — it words the orchestrator's standing orders and the
668
+ * rendered brief scaffold with it, so config and prompt can never disagree
669
+ * about which of them is holding the merge button.
670
+ *
671
+ * `promotion` is the queue-label sign-off: adding the label to an issue lets
672
+ * a worker claim it and starts spend, so POLICY.md must state its owner and
673
+ * a config that never answered defaults to the human — a gate whose owner is
674
+ * unstated must never resolve to the orchestrator.
628
675
  */
629
- authority: { merge: AuthorityHolder; release: AuthorityHolder };
676
+ authority: { merge: AuthorityHolder; release: AuthorityHolder; promotion?: AuthorityHolder };
630
677
  /**
631
678
  * Per-shape tool-call enforcement for releases and deploys. Optional only for
632
679
  * configs written before the tripwire existed; both omission and any shape key
@@ -666,6 +713,20 @@ export interface ProjectConfig {
666
713
  workspaceRoot: string;
667
714
  /** Cache of bare clones, so N runs share one fetch instead of N. */
668
715
  mirrorRoot: string;
716
+ /**
717
+ * Git treeish (commit SHAs or refs) on the base branch that every preserved
718
+ * continuation branch must contain before the dispatcher may reattach it
719
+ * (#428). A base safety fix protects only branches forked after it landed; a
720
+ * continuation forked before it still carries the dangerous code and, on a
721
+ * shared host, re-running the lifecycle suite it retains is what SIGTERMed
722
+ * the production daemon. With a marker configured, the dispatcher refuses to
723
+ * reattach any preserved branch missing it, holds the issue as `stale-base`
724
+ * (distinct from capacity or dependency holds), and never silently merges
725
+ * base into the user's work. Absent or empty, ordinary stale continuations
726
+ * keep today's behaviour. Optional and hand-edited, like
727
+ * {@link recoveryMerges}.
728
+ */
729
+ criticalBase?: string[];
669
730
  }
670
731
 
671
732
  /**
@@ -706,6 +767,16 @@ export interface ReadyIssue {
706
767
  updatedAt: string;
707
768
  }
708
769
 
770
+ /**
771
+ * One issue comment as the worker brief renders it: author login and verbatim
772
+ * body, in the tracker's own order (oldest first), so a later correction
773
+ * visibly supersedes an earlier note.
774
+ */
775
+ export interface IssueComment {
776
+ author: string;
777
+ body: string;
778
+ }
779
+
709
780
  /**
710
781
  * How a pull request ended, in tracker-agnostic terms. Lowercase because the
711
782
  * loop's vocabulary is lowercase; mapping GitHub's `MERGED`/`CLOSED`/`OPEN` onto
@@ -755,17 +826,10 @@ export interface PrDiff {
755
826
  * vocabulary that lives in three places drifts in three directions.
756
827
  */
757
828
  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. */
829
+ /** The settlement could not read the PR's diff, so no `changed:` file list
830
+ * could be derived — the one disclosure fault that survives, because a
831
+ * tree no one can read has nothing to disclose. */
762
832
  "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
833
  /** A test file left the tree and no rename in the PR accounts for it. */
770
834
  "test-file-deleted",
771
835
  /** A skip/only/focus marker appears on a line the PR added. */
@@ -864,6 +928,16 @@ export interface Tracker {
864
928
  * Complete — follows pagination to the end.
865
929
  */
866
930
  listOpenIssues(): Promise<ReadyIssue[]>;
931
+ /**
932
+ * The issue's comments, oldest first. The dispatcher renders them into the
933
+ * worker brief, so grooming the orchestrator posted as a comment reaches the
934
+ * worker's opening prompt without any runtime read.
935
+ *
936
+ * Throws when the tracker could not be read: the caller must never mistake
937
+ * "could not read" for "no comments", which is exactly the failure mode this
938
+ * read exists to prevent (#517).
939
+ */
940
+ listComments(issue: number): Promise<IssueComment[]>;
867
941
  addLabel(issue: number, label: string): Promise<void>;
868
942
  removeLabel(issue: number, label: string): Promise<void>;
869
943
  comment(issue: number, body: string): Promise<void>;
@@ -944,8 +1018,13 @@ export interface Tracker {
944
1018
  * Verify that a pull request is open, ready, and has a non-empty
945
1019
  * terminal-success check rollup. When `expectedHead` is supplied, also
946
1020
  * require the live head to match it.
1021
+ *
1022
+ * `opts.read` switches from the merge gate's "may this merge" to the read
1023
+ * surface's "what is this PR's state": a merged or closed PR is then a
1024
+ * reported fact rather than an `expected OPEN` refusal. Mutating verbs never
1025
+ * pass `read`, so their open-PR gate is unchanged.
947
1026
  */
948
- verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined>;
1027
+ verifyPr(url: string, expectedHead?: string, opts?: { read?: boolean }): Promise<PrVerification | undefined>;
949
1028
  /**
950
1029
  * The pull request's diff, or undefined when this adapter could not produce
951
1030
  * one — an unparseable URL, a deleted PR, a flaky network.
@@ -1010,6 +1089,8 @@ export const FAILURE_CLASSES = [
1010
1089
  "env-start-failure",
1011
1090
  "turn-cap-progress",
1012
1091
  "turn-cap-spinning",
1092
+ "wall-clock-cap-progress",
1093
+ "wall-clock-cap-spinning",
1013
1094
  "admin-kill",
1014
1095
  "ci-infra",
1015
1096
  "ci-deterministic",
@@ -1132,6 +1213,16 @@ export interface RunRecord {
1132
1213
  * copy of real work, so the tree was kept and the issue is held out of
1133
1214
  * dispatch until an operator acknowledges it. */
1134
1215
  salvageError?: string;
1216
+ /**
1217
+ * The model this attempt actually dispatched on, written at dispatch when the
1218
+ * project configures a failover chain (`modelFallbacks`). Absent means the
1219
+ * run predates the column or the project has no chain — never "the harness
1220
+ * downgraded"; the harness's own downgrade is carried as
1221
+ * `modelFallbackMessage` on the worker result instead, because that is
1222
+ * evidence about this run while this column is attribution for the chain
1223
+ * (#286).
1224
+ */
1225
+ model?: string;
1135
1226
  /** When an operator accepted the loss or recovered the tree by hand
1136
1227
  * (`unblock --force`). Clears the hold without erasing what happened. */
1137
1228
  salvageAckAt?: number;
@@ -1144,10 +1235,10 @@ export interface RunRecord {
1144
1235
  * exactly as an unflagged one does, and the flags are evidence for whoever
1145
1236
  * reviews the PR. Absent means the audit found nothing, or never ran. */
1146
1237
  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. */
1238
+ /** The worker's settlement report text for this attempt: the worker's own
1239
+ * narrative, with the `changed:` file list rewritten at settlement from
1240
+ * the PR's actual diff (#488). Absent means the run predates the column, or
1241
+ * was killed before the worker returned a report. */
1151
1242
  report?: string;
1152
1243
  /**
1153
1244
  * Why this run ended badly, and what the daemon did about it (#132). Absent
@@ -1162,6 +1253,15 @@ export interface RunRecord {
1162
1253
  recoveredAt?: number;
1163
1254
  }
1164
1255
 
1256
+ /**
1257
+ * A partial patch for {@link updateRun}. Every key is optional; an absent key
1258
+ * and an explicitly-`undefined` value both leave the column alone, while an
1259
+ * explicit `null` clears it (#468). `null` exists only for the columns a
1260
+ * caller may legitimately empty — a settle can never widen into a column it
1261
+ * did not mean to write.
1262
+ */
1263
+ export type RunPatch = { [K in keyof RunRecord]?: RunRecord[K] | null };
1264
+
1165
1265
  /** A one-shot turn budget waiting for one issue's next claimed attempt. */
1166
1266
  export interface TurnOverride {
1167
1267
  project: string;
@@ -1187,6 +1287,7 @@ export type AdmissionHoldReason =
1187
1287
  | "daily-spend-cap"
1188
1288
  | "plan-usage-cap"
1189
1289
  | "shutting-down"
1290
+ | "stale-base"
1190
1291
  | "unroutable:no-repo-label"
1191
1292
  | "unroutable:multiple-repo-labels"
1192
1293
  | "unroutable:unknown-repo";
@@ -1211,6 +1312,13 @@ export interface DispatchSummary {
1211
1312
  /** True only for system/API failures, never ordinary policy holds. */
1212
1313
  degraded: boolean;
1213
1314
  holds: AdmissionHoldSummary[];
1315
+ /** Runs the pass settled while it ran — merged PRs terminalised by the settle
1316
+ * sweep and by classification recovery. Optional: persisted old rows lack
1317
+ * it, so readers use `?? 0`. (#497) */
1318
+ settled?: number;
1319
+ /** True for a held pass's own record (#497): the queue was never routed, so
1320
+ * ready/routed/claimed are absent queue facts, not an empty queue. */
1321
+ paused?: boolean;
1214
1322
  }
1215
1323
 
1216
1324
  /** Admission holds that indicate repairable friction rather than normal flow control. */
@@ -1220,6 +1328,7 @@ export type FrictionAdmissionReason =
1220
1328
  | "parent-lookup-error"
1221
1329
  | "issue-state-lookup-error"
1222
1330
  | "open-pr-lookup-error"
1331
+ | "stale-base"
1223
1332
  | "unroutable:no-repo-label"
1224
1333
  | "unroutable:multiple-repo-labels"
1225
1334
  | "unroutable:unknown-repo";
@@ -1261,11 +1370,22 @@ export interface FrictionSignal {
1261
1370
  * today's digest already been handed over?" without asking the model to
1262
1371
  * remember, which is the memory #123 found unreliable.
1263
1372
  *
1373
+ * The remaining values are the interrupt categories themselves. Handing a
1374
+ * report over as `fleet-stopped` or `tier2` (or `decision-needed`,
1375
+ * `confirmed-failure`) is the escalation handoff: the project's reporting
1376
+ * policy decides between sending now and holding, exactly as it does for a
1377
+ * daemon escalation of that category, and an identical retry is refused only
1378
+ * while the earlier handoff is still undelivered (#453).
1379
+ *
1264
1380
  * Declared as data for the same reason as {@link REPORT_SCOPES}: the CLI verb
1265
1381
  * that accepts a kind and the outbox that renders one enumerate the same list,
1266
1382
  * so a third kind cannot be added while either still knows only two.
1267
1383
  */
1268
- export const REPORT_KINDS = ["material", "digest"] as const;
1384
+ export const REPORT_KINDS = [
1385
+ "material",
1386
+ "digest",
1387
+ ...INTERRUPT_CATEGORIES.filter((category) => category !== "material"),
1388
+ ] as const;
1269
1389
 
1270
1390
  export type ReportKind = (typeof REPORT_KINDS)[number];
1271
1391
 
@@ -1377,7 +1497,41 @@ export interface DigestBacklog {
1377
1497
  heldNoticeCount: number;
1378
1498
  /** Subset held only for the next availability window. */
1379
1499
  availabilityHeldNoticeCount?: number;
1500
+ /** Oldest held notice across both held groups. */
1380
1501
  heldNoticeOldestAt?: number;
1502
+ /** Oldest held notice that is digest-bound (`releaseOnAvailable` false). */
1503
+ digestHeldNoticeOldestAt?: number;
1504
+ /** Oldest held notice waiting only for the availability window. */
1505
+ availabilityHeldNoticeOldestAt?: number;
1506
+ }
1507
+
1508
+ /** Where one captured raw idea is in its lifecycle (#298). `pending` is a raw
1509
+ * idea awaiting grooming; `groomed` became an issue (#300, which records the
1510
+ * issue URL); `dismissed` was dropped without becoming anything. */
1511
+ export const INTAKE_STATES = ["pending", "groomed", "dismissed"] as const;
1512
+
1513
+ export type IntakeState = (typeof INTAKE_STATES)[number];
1514
+
1515
+ /** One raw idea captured by `omp-conductor intake`, durable in the store so an
1516
+ * idea from 02:00 is still there after a restart (#299). */
1517
+ export interface IntakeItem {
1518
+ id: string;
1519
+ project: string;
1520
+ /** The idea text verbatim, as captured. */
1521
+ text: string;
1522
+ createdAt: number;
1523
+ state: IntakeState;
1524
+ /** The issue this idea became, once #300 grooms it. */
1525
+ issueUrl?: string;
1526
+ /** When the item was resolved into an issue (#300) or dismissed. */
1527
+ groomedAt?: number;
1528
+ }
1529
+
1530
+ /** What a caller hands over. The store owns the id, the state and the timestamps. */
1531
+ export interface IntakeDraft {
1532
+ project: string;
1533
+ text: string;
1534
+ at: number;
1381
1535
  }
1382
1536
 
1383
1537
  /**
@@ -1460,7 +1614,8 @@ export interface LabelOp {
1460
1614
  export interface Store {
1461
1615
  /** A claimed run atomically consumes and applies its issue's pending turn override. */
1462
1616
  createRun(r: Omit<RunRecord, "id">): RunRecord;
1463
- updateRun(id: string, patch: Partial<RunRecord>): void;
1617
+ /** Partial patch; an explicit `null` clears a column, `undefined`/absence leaves it alone (#468). */
1618
+ updateRun(id: string, patch: RunPatch): void;
1464
1619
  getRun(id: string): RunRecord | undefined;
1465
1620
  /** Runs whose issue is occupied: a live worker, or a green PR awaiting merge. */
1466
1621
  activeRuns(project: string): RunRecord[];
@@ -1471,6 +1626,11 @@ export interface Store {
1471
1626
  /** Newest attempt per issue for the live board. Non-merged work remains
1472
1627
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
1473
1628
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1629
+ /** Every attempt that recorded one PR URL, newest first. Mediated PR verbs
1630
+ * resolve ownership through this, not through `recentRuns`' newest-attempt-
1631
+ * per-issue view — a requeued continuation must never hide the PR its
1632
+ * predecessor opened. Merged rows keep the same recent-history bound. */
1633
+ runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[];
1474
1634
  /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
1475
1635
  runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
1476
1636
  /** Replace the current live-head health row for one routed repository. */
@@ -1500,6 +1660,9 @@ export interface Store {
1500
1660
  * tail` resolves an issue number to a transcript through this; the number is
1501
1661
  * what an operator has, the run id is not. */
1502
1662
  latestRun(project: string, issue: number): RunRecord | undefined;
1663
+ /** Every attempt for one issue, oldest first, so an escalation can group the
1664
+ * continuation budget by failure class (#439). */
1665
+ runsForIssue(project: string, issue: number): RunRecord[];
1503
1666
  /** Persist a one-shot ceiling and append the operator action to its audit ledger. */
1504
1667
  setTurnOverride(project: string, issue: number, maxTurns: number, setAt?: number): void;
1505
1668
  turnOverride(project: string, issue: number): number | undefined;
@@ -1511,16 +1674,24 @@ export interface Store {
1511
1674
  project: string,
1512
1675
  opts?: { issue?: number; limit?: number },
1513
1676
  ): 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
1677
  runsStartedSince(project: string, sinceEpochMs: number): number;
1520
1678
  spendSince(project: string, sinceEpochMs: number): number;
1679
+ /** The rows one stats window needs (#282): every run settling at or after
1680
+ * `sinceEpochMs`, plus the full attempt chain of each issue whose merge
1681
+ * settled there (so continuation chains collapse into one journey), ordered
1682
+ * by issue then startedAt. Read-only. */
1683
+ statsRuns(project: string, sinceEpochMs: number): RunRecord[];
1684
+ /** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */
1685
+ ghCallsBetween(sinceDay: string, untilDay: string): number;
1521
1686
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
1522
1687
  * same event. */
1523
1688
  wasNotified(key: string): boolean;
1689
+ /** Whether an escalation handoff with this project, category and folded text
1690
+ * is still owed: a same-text report still in the outbox, or a same-text
1691
+ * held notice not yet digested. The CLI report surface refuses an identical
1692
+ * retry only while this is true (#453) — once the first handoff lands, the
1693
+ * same text is a new occurrence, not a double-send. */
1694
+ hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean;
1524
1695
  recordDispatch(project: string, summary: DispatchSummary): void;
1525
1696
  latestDispatch(project: string): DispatchSummary | undefined;
1526
1697
  /** The newest `digest:` dedupe key a project has run toward without ending in
@@ -1544,6 +1715,13 @@ export interface Store {
1544
1715
  getMaterialEvent(id: string): MaterialEvent | undefined;
1545
1716
  /** Ordinary material outcomes still owed, oldest first and bounded. */
1546
1717
  undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
1718
+ /** Persist one raw idea, `pending`, for later grooming (#299). */
1719
+ recordIntake(draft: IntakeDraft): IntakeItem;
1720
+ /** Ideas still `pending`, oldest first — what `intake list` and `status` read. */
1721
+ pendingIntake(project: string): IntakeItem[];
1722
+ /** Resolve one idea to `groomed` (recording the issue URL #300 chose) or
1723
+ * `dismissed`. `false` when the id is unknown. */
1724
+ resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean;
1547
1725
  /** Count and age source for status and digest prompt bounds. */
1548
1726
  digestBacklog(project: string): DigestBacklog;
1549
1727
  /** Add one bounded observation to the per-day friction rollup. */
@@ -1655,6 +1833,8 @@ export interface Store {
1655
1833
  createDecision(draft: DecisionDraft): DecisionRecord;
1656
1834
  /** Everything still owed an answer, oldest first — what a tick digest reads. */
1657
1835
  openDecisions(project: string): DecisionRecord[];
1836
+ /** One row by id, whatever its state — what a bounded ask polls while waiting. */
1837
+ decision(id: string): DecisionRecord | undefined;
1658
1838
  /** Answer or withdraw one. `false` when the id is unknown or already closed,
1659
1839
  * so a double-resolve cannot overwrite the first answer. */
1660
1840
  resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean;
@@ -1805,6 +1985,10 @@ export const VERB_NAMES = [
1805
1985
  "conductor_pr_merge",
1806
1986
  "conductor_label",
1807
1987
  "conductor_release",
1988
+ /** Request the fleet upgrade itself to a published conductor release. The
1989
+ * install shape gates it like a release shape; execution detaches from this
1990
+ * session and the daemon, and the first tick after the restart verifies. */
1991
+ "conductor_install",
1808
1992
  /** The one read verb. It answers with the merge gate's own verdict, so
1809
1993
  * "pushed-green" is the dispatcher's reading of the PR rather than a claim the
1810
1994
  * 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 ` +