omp-conductor 0.12.0 → 0.14.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/src/types.ts CHANGED
@@ -61,6 +61,9 @@ export interface Caps {
61
61
  /** Turn ceiling for one worker — catches loops that are burning tokens
62
62
  * without converging. */
63
63
  workerMaxTurns: number;
64
+ /** Maximum turn budget an operator may assign to one issue's next attempt.
65
+ * Defaults to twice that issue's effective `workerMaxTurns`. */
66
+ workerMaxTurnsCeiling: number;
64
67
  /** Wall-clock ceiling for one worker (90 min): a session that is merely
65
68
  * stuck spends no turns, so turns alone cannot detect it. */
66
69
  workerWallClockMs: number;
@@ -133,8 +136,8 @@ export interface RepoTarget {
133
136
  * `escalations` interrupts only for a tier-2 decision. `material` reports every
134
137
  * material event as it happens. `decisions` sits between them: a decision the
135
138
  * session needs, or a condition that stops the fleet, interrupts immediately;
136
- * every other material event accumulates and ships with the next tick report —
137
- * one message on a schedule instead of a ping per merge (#138).
139
+ * every other material event accumulates and ships with the configured digest
140
+ * instead of as a ping per merge (#138).
138
141
  */
139
142
  export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const;
140
143
 
@@ -174,6 +177,24 @@ export const INTERRUPT_CATEGORIES = [
174
177
 
175
178
  export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
176
179
 
180
+ /** Operator-local weekdays used by a weekly availability window. */
181
+ export const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
182
+
183
+ export type Weekday = (typeof WEEKDAYS)[number];
184
+
185
+ /**
186
+ * One operator-local interruption window. A day names the day the window
187
+ * opens; when `start` is later than `end`, it closes on the following day.
188
+ */
189
+ export interface WeeklyAvailability {
190
+ timezone: string;
191
+ days: Weekday[];
192
+ start: string;
193
+ end: string;
194
+ /** Categories that may still interrupt outside the window. */
195
+ bypass: InterruptCategory[];
196
+ }
197
+
177
198
  /** When the daily/report rollup is due. */
178
199
  export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
179
200
 
@@ -187,6 +208,8 @@ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
187
208
  export interface ReportingPolicy {
188
209
  interruptOn: InterruptCategory[];
189
210
  digest: { cadence: DigestCadence; at?: string; timezone?: string };
211
+ /** Absent preserves the legacy 24-hour interruption behaviour. */
212
+ availability?: WeeklyAvailability;
190
213
  /** Set when the policy came from a legacy `scope` preset. */
191
214
  scopePreset?: ReportScope;
192
215
  }
@@ -358,6 +381,8 @@ export const RELEASE_REQUIREMENTS = [
358
381
  "no-open-prs",
359
382
  /** Nothing still carries the queue label: the batch is finished, not paused. */
360
383
  "queue-drained",
384
+ /** The newest observed post-merge check for the released repo's base branch is green. */
385
+ "base-branch-green",
361
386
  /** The epic this release closes has no open children left. */
362
387
  "epic-children-closed",
363
388
  ] as const;
@@ -661,6 +686,8 @@ export type PrState = "merged" | "closed" | "open";
661
686
  /** Result of independently checking a worker's claimed green pull request. */
662
687
  export interface PrVerification {
663
688
  status: "green" | "pending" | "failed";
689
+ /** Full live head SHA read in the same tracker request as the verdict. */
690
+ headSha: string;
664
691
  reason: string;
665
692
  }
666
693
 
@@ -703,6 +730,10 @@ export const SETTLEMENT_FLAG_KINDS = [
703
730
  "changed-line-missing",
704
731
  /** `changed:` named a path the PR never touched. The weaker direction. */
705
732
  "unmatched-claim",
733
+ /** The same file appears as both claimed-but-untouched and
734
+ * touched-but-unclaimed — the `changed:` line's format defeated the parser;
735
+ * read the diff directly. */
736
+ "report-format-unparsed",
706
737
  /** A test file left the tree and no rename in the PR accounts for it. */
707
738
  "test-file-deleted",
708
739
  /** A skip/only/focus marker appears on a line the PR added. */
@@ -711,6 +742,10 @@ export const SETTLEMENT_FLAG_KINDS = [
711
742
  "assertions-removed",
712
743
  /** A named timeout in a test file went up. */
713
744
  "test-timeout-raised",
745
+ /** A terminal run recovered an OPEN PR by exact issue, repo and branch identity. */
746
+ "pr-adopted",
747
+ /** A workflow on the merge commit's base branch failed after settlement. */
748
+ "base-branch-red",
714
749
  ] as const;
715
750
 
716
751
  export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
@@ -722,7 +757,8 @@ export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
722
757
  */
723
758
  export interface SettlementFlag {
724
759
  kind: SettlementFlagKind;
725
- /** Repo-relative path, or `(report)` for a finding about the report itself. */
760
+ /** Repo-relative path, `(report)` for report evidence, or `(base branch)` for
761
+ * a post-merge workflow result. */
726
762
  file: string;
727
763
  /** 1-based line in the side of the diff the evidence came from: the
728
764
  * post-image for an added line, the pre-image for a removed one. */
@@ -736,24 +772,52 @@ export interface SettlementFlag {
736
772
  /** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
737
773
  export type IssueState = "open" | "closed";
738
774
 
775
+ /** Read-after-write issue facts used to validate an eventually-consistent queue candidate. */
776
+ export interface IssueSnapshot {
777
+ readonly state: IssueState;
778
+ readonly labels: readonly string[];
779
+ }
780
+
739
781
  /**
740
782
  * An OPEN pull request that already closes an issue, as admission sees it.
741
783
  *
742
- * The head branch travels with the URL because admission has two different
743
- * questions to answer about the same PR, and only one of them the URL can
744
- * answer. "Is there finished work here" is a URL question. "Is this *our*
745
- * retained work, resumed" is a branch question, because a run can retain a
746
- * branch and never produce a PR: on 2026-08-09T00:47Z veltro#324 attempt 1 was
747
- * killed at the turns cap before its worker opened one, and the PR that later
748
- * appeared on that exact branch (chad#438) could never be matched by URL
749
- * equality against a `prUrl` the terminal run never recorded (#50).
784
+ * The repository travels with the URL because recovery has to prove three
785
+ * independent identities before adopting a PR onto a terminal run: closing
786
+ * issue, retained branch, and routed repository (#245). Admission needs only
787
+ * the URL/branch pair, but weakening the shared answer would make recovery
788
+ * parse provenance back out of a presentation URL.
789
+ *
790
+ * The head branch recognises a continuation the store never saw a PR for: on
791
+ * 2026-08-09T00:47Z veltro#324 attempt 1 was killed at the turns cap before its
792
+ * worker opened one, and the PR that later appeared on that exact branch
793
+ * (chad#438) could never be matched by URL equality against a `prUrl` the
794
+ * terminal run never recorded (#50).
750
795
  */
751
796
  export interface OpenCloser {
752
797
  url: string;
753
798
  /** Head branch of the open PR, for retained-continuation identity. */
754
799
  headRefName: string;
800
+ /** Canonical `owner/repo` identity supplied by the tracker. */
801
+ repo: string;
802
+ }
803
+ /** The merge identity needed to observe the exact base-branch push it produced. */
804
+ export interface MergedPrInfo {
805
+ mergeSha: string;
806
+ baseRef: string;
807
+ }
808
+
809
+ /** One GitHub Actions workflow run, normalised before it crosses the tracker port. */
810
+ export interface WorkflowRun {
811
+ id: number;
812
+ workflowId: number;
813
+ name: string;
814
+ status: string;
815
+ conclusion?: string;
816
+ url: string;
817
+ createdAt: string;
755
818
  }
756
819
 
820
+
757
821
  /**
758
822
  * Deliberately narrow so a Gitea or local-file tracker can drop in later.
759
823
  * Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
@@ -786,44 +850,70 @@ export interface Tracker {
786
850
  */
787
851
  parentOf(issue: number): Promise<number | undefined>;
788
852
  /**
789
- * The OPEN pull request that already closes `issue`, or undefined when none
790
- * does.
853
+ * The first OPEN pull request that already closes `issue`, or undefined when
854
+ * none does.
791
855
  *
792
- * Admission has to ask the tracker because the store cannot answer. The busy
793
- * set is built from run rows, so it only knows work *this* database recorded:
794
- * a migration onto the daemon, a wiped or relocated state directory, a
795
- * restore onto a new host, or simply a database younger than the PRs all
796
- * present pushed-and-open work as an untouched queue item. The tracker is the
797
- * only party that remembers across all of those.
798
- *
799
- * Returns {@link OpenCloser} rather than a bare URL because the branch is the
800
- * half admission needs to recognise a continuation the store never saw a PR
801
- * for; see that type for the veltro#324 case that forced the widening.
856
+ * Admission asks this any-open question because the store only knows work
857
+ * recorded in this database. A migrated daemon, restored host, or younger
858
+ * database can otherwise dispatch duplicate work onto an issue whose PR is
859
+ * already open.
802
860
  */
803
861
  openCloserFor(issue: number): Promise<OpenCloser | undefined>;
862
+ /**
863
+ * Every OPEN pull request that closes `issue`.
864
+ *
865
+ * Salvage recovery must inspect the full answer: an unrelated closer may be
866
+ * listed before the PR whose branch and repository exactly match the
867
+ * recovered run. An empty array means no OPEN closer; a thrown lookup remains
868
+ * fail-closed.
869
+ */
870
+ openClosersFor(issue: number): Promise<OpenCloser[]>;
804
871
  /**
805
872
  * Whether an issue is still open, or undefined when tracker/network state is
806
873
  * ambiguous. Cleanup must never interpret undefined as permission to delete.
807
874
  */
808
875
  issueState(issue: number): Promise<IssueState | undefined>;
876
+ /**
877
+ * Direct REST state and labels for final admission validation, or undefined
878
+ * when either fact cannot be read safely.
879
+ */
880
+ issueSnapshot(issue: number): Promise<IssueSnapshot | undefined>;
809
881
  /**
810
882
  * The state of one specific pull request, or undefined when this adapter
811
883
  * could not tell — a network failure, a deleted PR, a URL it cannot parse.
812
884
  * Undefined never means "no".
813
885
  *
814
- * Deliberately separate from {@link Tracker.openCloserFor}, which answers a
815
- * different question: that one asks "should this candidate be admitted", and
816
- * answers undefined for merged, closed-unmerged, no PR at all and a deleted
817
- * PR alike, because admission treats all four the same. This one asks "did
886
+ * Deliberately separate from {@link Tracker.openCloserFor}, which asks
887
+ * whether any OPEN closing PR exists and answers undefined for merged,
888
+ * closed-unmerged, no PR at all and a deleted PR alike. This one asks "did
818
889
  * *this* run's PR land", where those answers are opposites. Collapsing them
819
890
  * is how a PR a human rejected gets recorded as merged.
820
891
  */
821
892
  prState(url: string): Promise<PrState | undefined>;
893
+ /** The merge commit and base ref for a merged pull request. */
894
+ mergedPrInfo(url: string): Promise<MergedPrInfo | undefined>;
895
+ /** Workflow runs GitHub associated with one exact commit SHA. */
896
+ workflowRunsAt(
897
+ repo: string,
898
+ sha: string,
899
+ opts?: { event?: string; branch?: string },
900
+ ): Promise<WorkflowRun[] | undefined>;
901
+ /** The live commit at one repository branch, or undefined when unreadable. */
902
+ branchHead(repo: string, branch: string): Promise<string | undefined>;
903
+ /** The newest run of this workflow on the base branch before `before`.
904
+ * `null` means no earlier run; `undefined` means the history could not be read. */
905
+ previousWorkflowRun(
906
+ repo: string,
907
+ workflowId: number,
908
+ branch: string,
909
+ before: string,
910
+ ): Promise<WorkflowRun | null | undefined>;
822
911
  /**
823
- * Verify that a worker's pull request is open, ready, still at the reported
824
- * head, and has a non-empty terminal-success check rollup.
912
+ * Verify that a pull request is open, ready, and has a non-empty
913
+ * terminal-success check rollup. When `expectedHead` is supplied, also
914
+ * require the live head to match it.
825
915
  */
826
- verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
916
+ verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined>;
827
917
  /**
828
918
  * The pull request's diff, or undefined when this adapter could not produce
829
919
  * one — an unparseable URL, a deleted PR, a flaky network.
@@ -899,6 +989,9 @@ export const FAILURE_CLASSES = [
899
989
  "settlement-stuck",
900
990
  "provider-credit",
901
991
  "provider-transient",
992
+ /** A reviewer closed pushed-green or pushed-pending work without merging it:
993
+ * a review decision, not a worker failure. */
994
+ "returned-for-revision",
902
995
  "unknown",
903
996
  ] as const;
904
997
 
@@ -923,11 +1016,34 @@ export const RECOVERY_ACTIONS = [
923
1016
 
924
1017
  export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number];
925
1018
 
1019
+ /** What the daemon learned about workflows on a mediated merge's base commit. */
1020
+ export const BASE_CHECK_STATES = [
1021
+ "pending",
1022
+ "green",
1023
+ "red",
1024
+ "red-preexisting",
1025
+ "unknown",
1026
+ ] as const;
1027
+
1028
+ export type BaseCheckState = (typeof BASE_CHECK_STATES)[number];
1029
+
1030
+ /** Current push-workflow health at a routed repository's live base head. */
1031
+ export interface BaseHealth {
1032
+ repo: string;
1033
+ branch: string;
1034
+ headSha: string;
1035
+ verdict: "green" | "red" | "pending" | "unknown";
1036
+ runsCount: number;
1037
+ detail?: string;
1038
+ checkedAt: number;
1039
+ }
1040
+
926
1041
  /**
927
1042
  * Execution state is separate from the tracker's own labels on purpose: labels
928
1043
  * are coarse and human-editable, while the loop needs to distinguish "pushed
929
1044
  * and green, waiting on merge" from "merged" to decide what to do on restart.
930
1045
  */
1046
+
931
1047
  export type RunState =
932
1048
  | "claimed"
933
1049
  | "running"
@@ -940,6 +1056,9 @@ export type RunState =
940
1056
  | "blocked"
941
1057
  | "failed"
942
1058
  | "killed"
1059
+ /** Terminal operator action. The worker cannot resume and the attempt does
1060
+ * not consume implementation-failure or continuation budgets. */
1061
+ | "stopped"
943
1062
  /** In flight when its daemon process died; reconciled at the next startup. */
944
1063
  | "orphaned";
945
1064
 
@@ -967,6 +1086,12 @@ export interface RunRecord {
967
1086
  prUrl?: string;
968
1087
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
969
1088
  headSha?: string;
1089
+ /** Merge commit and actual base ref read from the merged pull request. */
1090
+ mergeSha?: string;
1091
+ baseRef?: string;
1092
+ /** Post-merge workflow verdict. Pending is revisited by the tick sweep. */
1093
+ baseCheck?: BaseCheckState;
1094
+ baseCheckAt?: number;
970
1095
  /** Commit this run's uncommitted work was preserved as before its worktree
971
1096
  * was removed, on the run's own branch. Absent means the daemon found
972
1097
  * nothing to save, or never looked — see {@link RunRecord.salvageError}. */
@@ -1005,11 +1130,22 @@ export interface RunRecord {
1005
1130
  recoveredAt?: number;
1006
1131
  }
1007
1132
 
1133
+ /** A one-shot turn budget waiting for one issue's next claimed attempt. */
1134
+ export interface TurnOverride {
1135
+ project: string;
1136
+ issue: number;
1137
+ maxTurns: number;
1138
+ setAt: number;
1139
+ }
1140
+
1008
1141
  export type AdmissionHoldReason =
1009
1142
  | "capacity"
1010
1143
  | "issue-active"
1011
1144
  | "failed-attempts"
1012
1145
  | "continuations"
1146
+ | "issue-closed"
1147
+ | "issue-state-lookup-error"
1148
+ | "issue-dequeued"
1013
1149
  | "parent-lookup-error"
1014
1150
  | "sibling-active"
1015
1151
  | "repo-active"
@@ -1049,6 +1185,7 @@ export type FrictionAdmissionReason =
1049
1185
  | "failed-attempts"
1050
1186
  | "continuations"
1051
1187
  | "parent-lookup-error"
1188
+ | "issue-state-lookup-error"
1052
1189
  | "open-pr-lookup-error"
1053
1190
  | "unroutable:no-repo-label"
1054
1191
  | "unroutable:multiple-repo-labels"
@@ -1173,6 +1310,43 @@ export interface ReportEnqueue {
1173
1310
  deduped: boolean;
1174
1311
  }
1175
1312
 
1313
+ /** Maximum ledger rows of each kind placed in one digest prompt. Older rows
1314
+ * stay first, so a busy day drains deterministically over later digests. */
1315
+ export const DIGEST_BACKLOG_LIMIT = 20;
1316
+
1317
+ /** One ordinary material outcome waiting for a rendered digest (#274). */
1318
+ export interface MaterialEvent {
1319
+ id: string;
1320
+ project: string;
1321
+ category: string;
1322
+ summary: string;
1323
+ evidence: string;
1324
+ occurredAt: number;
1325
+ recordedAt: number;
1326
+ /** The accepted outbox row that took responsibility for this event. */
1327
+ digestReportId?: string;
1328
+ }
1329
+
1330
+ /** What the orchestrator records when an outcome happens. */
1331
+ export interface MaterialEventDraft {
1332
+ project: string;
1333
+ category: string;
1334
+ summary: string;
1335
+ evidence: string;
1336
+ occurredAt: number;
1337
+ recordedAt: number;
1338
+ }
1339
+
1340
+ /** Counts and oldest timestamps for the two distinct digest sources. */
1341
+ export interface DigestBacklog {
1342
+ materialCount: number;
1343
+ materialOldestAt?: number;
1344
+ heldNoticeCount: number;
1345
+ /** Subset held only for the next availability window. */
1346
+ availabilityHeldNoticeCount?: number;
1347
+ heldNoticeOldestAt?: number;
1348
+ }
1349
+
1176
1350
  /**
1177
1351
  * Where one operator decision stands (#136).
1178
1352
  *
@@ -1251,6 +1425,7 @@ export interface LabelOp {
1251
1425
  * the two disagree.
1252
1426
  */
1253
1427
  export interface Store {
1428
+ /** A claimed run atomically consumes and applies its issue's pending turn override. */
1254
1429
  createRun(r: Omit<RunRecord, "id">): RunRecord;
1255
1430
  updateRun(id: string, patch: Partial<RunRecord>): void;
1256
1431
  getRun(id: string): RunRecord | undefined;
@@ -1263,6 +1438,17 @@ export interface Store {
1263
1438
  /** Newest attempt per issue for the live board. Non-merged work remains
1264
1439
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
1265
1440
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1441
+ /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
1442
+ runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
1443
+ /** Replace the current live-head health row for one routed repository. */
1444
+ upsertBaseHealth(project: string, row: BaseHealth): void;
1445
+ /** Current live-head health, ordered by routed repository name. */
1446
+ baseHealth(project: string): BaseHealth[];
1447
+ /** Recently merged routed repository/branch pairs that establish health scope. */
1448
+ mergedRepoBranches(
1449
+ project: string,
1450
+ sinceEpochMs: number,
1451
+ ): { repo: string; baseRef?: string }[];
1266
1452
  /** Newest attempt per issue that preserved work or failed to, so `status`
1267
1453
  * can name every WIP tip a re-claim would build on and every tree that is
1268
1454
  * still the only copy. */
@@ -1281,6 +1467,17 @@ export interface Store {
1281
1467
  * tail` resolves an issue number to a transcript through this; the number is
1282
1468
  * what an operator has, the run id is not. */
1283
1469
  latestRun(project: string, issue: number): RunRecord | undefined;
1470
+ /** Persist a one-shot ceiling and append the operator action to its audit ledger. */
1471
+ setTurnOverride(project: string, issue: number, maxTurns: number, setAt?: number): void;
1472
+ turnOverride(project: string, issue: number): number | undefined;
1473
+ /** Administrative deletion; claimed runs consume inside `createRun`'s transaction. */
1474
+ clearTurnOverride(project: string, issue: number): void;
1475
+ listTurnOverrides(project: string): TurnOverride[];
1476
+ /** Durable override actions, newest first; consumption never removes these rows. */
1477
+ turnOverrideLedger(
1478
+ project: string,
1479
+ opts?: { issue?: number; limit?: number },
1480
+ ): TurnOverride[];
1284
1481
  /** Every attempt of one issue that settled with a report, in attempt order.
1285
1482
  * The settlement audit pools these as prior disclosures when a later
1286
1483
  * attempt is reconciled (#199). Rows with no report (pre-#199, or killed
@@ -1296,12 +1493,26 @@ export interface Store {
1296
1493
  /** The newest `digest:` dedupe key a project has run toward without ending in
1297
1494
  * failure — what the off-schedule-digest refusal compares against (#229). */
1298
1495
  lastDigestDedupeKey(project: string): string | undefined;
1299
- /** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
1496
+ /** Hold a tier-2 escalation the interrupt policy or availability window deferred. */
1300
1497
  addHeldNotice(notice: HeldNoticeDraft): void;
1301
- /** Held notices still owed (not yet re-surfaced by a digest pass). */
1302
- undigestedNotices(project: string): HeldNotice[];
1303
- /** Mark every undigested notice for a project as re-surfaced. */
1304
- markNoticesDigested(project: string, at: number): void;
1498
+ /**
1499
+ * Held notices still owed, oldest first and bounded. `availabilityOnly`
1500
+ * selects notices that may be released at the next configured opening.
1501
+ */
1502
+ undigestedNotices(
1503
+ project: string,
1504
+ limit?: number,
1505
+ availabilityOnly?: boolean,
1506
+ categories?: readonly InterruptCategory[],
1507
+ urgent?: boolean,
1508
+ ): HeldNotice[];
1509
+ /** Persist one ordinary material outcome without sending it. */
1510
+ recordMaterialEvent(event: MaterialEventDraft): MaterialEvent;
1511
+ getMaterialEvent(id: string): MaterialEvent | undefined;
1512
+ /** Ordinary material outcomes still owed, oldest first and bounded. */
1513
+ undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
1514
+ /** Count and age source for status and digest prompt bounds. */
1515
+ digestBacklog(project: string): DigestBacklog;
1305
1516
  /** Add one bounded observation to the per-day friction rollup. */
1306
1517
  recordFriction(project: string, observation: FrictionObservation): void;
1307
1518
  /** Repeated signals not surfaced within the supplied cooldown window. */
@@ -1331,7 +1542,35 @@ export interface Store {
1331
1542
  * suppressed without asking the model what it sent yesterday.
1332
1543
  */
1333
1544
  enqueueReport(draft: ReportDraft): ReportEnqueue;
1545
+ /** Atomically hand a digest to the outbox and associate only the named
1546
+ * material events and deferred escalations. A handoff deduplicated by its
1547
+ * optional daily key consumes nothing new. */
1548
+ enqueueDigestReport(
1549
+ draft: ReportDraft,
1550
+ materialEventIds: readonly string[],
1551
+ heldNoticeIds: readonly string[],
1552
+ ): ReportEnqueue;
1553
+ /** Atomically enqueue a working-hours catch-up unless a due digest currently
1554
+ * owns the handoff window. `undefined` leaves every notice unowned. */
1555
+ enqueueAvailabilityReport(
1556
+ draft: ReportDraft,
1557
+ heldNoticeIds: readonly string[],
1558
+ ): ReportEnqueue | undefined;
1334
1559
  getReport(id: string): ReportRecord | undefined;
1560
+ /** Atomically remove an unsent material report from the outbox and preserve
1561
+ * its body as a held notice after policy or availability changes. */
1562
+ deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean;
1563
+ /** Remove a still-pending working-hours catch-up and make its associated
1564
+ * notices digest-eligible again after a live policy change. */
1565
+ releasePendingAvailabilityReport(id: string, project: string, possibleRepeat?: boolean): boolean;
1566
+ /** Give one due digest a bounded, once-per-day lease on availability-held
1567
+ * notices. Pending catch-ups are released inside the same transaction. */
1568
+ reserveAvailabilityDigest(
1569
+ project: string,
1570
+ cycleKey: string,
1571
+ at: number,
1572
+ expiresAt: number,
1573
+ ): boolean;
1335
1574
  /** `pending` rows whose backoff has elapsed, oldest first. */
1336
1575
  dueReports(project: string, now: number, limit: number): ReportRecord[];
1337
1576
  /** Atomically take a due `pending` row into `sending` under `attemptId`, so
@@ -1468,24 +1707,36 @@ export interface Escalation {
1468
1707
  * escalation pages now or is held for the digest under the project's
1469
1708
  * `reporting.interruptOn` policy (#229). */
1470
1709
  category?: InterruptCategory;
1710
+ /** Deliver now even when policy would defer this category to the digest.
1711
+ * Used only when the digest's own orchestrator loop is unavailable. */
1712
+ urgent?: true;
1471
1713
  }
1472
1714
 
1473
- /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
1474
1715
  export interface HeldNotice {
1475
1716
  id: string;
1476
1717
  category: InterruptCategory;
1477
1718
  summary: string;
1478
1719
  detail: string;
1479
1720
  createdAt: number;
1721
+ /**
1722
+ * This notice was otherwise interruptible and was held only by the
1723
+ * availability window. It may be surfaced when the current policy opens.
1724
+ */
1725
+ releaseOnAvailable?: true;
1726
+ /** Urgent recovery may bypass category batching, but never availability. */
1727
+ urgent?: true;
1480
1728
  }
1481
1729
 
1482
- /** What it takes to persist one held notice. */
1483
1730
  export interface HeldNoticeDraft {
1731
+ /** Caller-supplied identity when the handoff must print or deduplicate it. */
1732
+ id?: string;
1484
1733
  project: string;
1485
1734
  category: InterruptCategory;
1486
1735
  summary: string;
1487
1736
  detail: string;
1488
1737
  createdAt: number;
1738
+ releaseOnAvailable?: true;
1739
+ urgent?: true;
1489
1740
  }
1490
1741
 
1491
1742
  /**
@@ -1501,6 +1752,7 @@ export const DEFAULT_CAPS: Caps = {
1501
1752
  // guard silently watches the wrong meter (#110).
1502
1753
  planUsage: null,
1503
1754
  workerMaxTurns: 120,
1755
+ workerMaxTurnsCeiling: 240,
1504
1756
  workerWallClockMs: 90 * 60 * 1000,
1505
1757
  maxAttemptsPerIssue: 2,
1506
1758
  maxContinuationsPerIssue: 2,