omp-conductor 0.13.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.
@@ -9,27 +9,53 @@
9
9
 
10
10
  import type { ReportingPolicy } from "./types.ts";
11
11
 
12
- /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
13
- export function localDayKey(at: number, timezone?: string): string {
14
- const parts = new Intl.DateTimeFormat("en-CA", {
15
- timeZone: timezone,
16
- year: "numeric",
17
- month: "2-digit",
18
- day: "2-digit",
19
- }).formatToParts(new Date(at));
20
- const get = (type: "year" | "month" | "day"): string =>
21
- parts.find((p) => p.type === type)?.value ?? "00";
22
- return `${get("year")}-${get("month")}-${get("day")}`;
12
+ const MINUTE_MS = 60_000;
13
+ const NEXT_DIGEST_HORIZON_MS = 3 * 24 * 60 * MINUTE_MS;
14
+
15
+ export type DigestScheduleState =
16
+ | { mode: "disabled" }
17
+ | { mode: "per-tick" }
18
+ | { mode: "due"; timezone: string }
19
+ | { mode: "scheduled"; timezone: string; nextAt?: number };
20
+
21
+ const localMinuteFormatters = new Map<string, Intl.DateTimeFormat>();
22
+ interface CachedDigestSchedule {
23
+ lastDigestDayKey: string | undefined;
24
+ from: number;
25
+ until: number;
26
+ state: Extract<DigestScheduleState, { mode: "scheduled" }>;
27
+ }
28
+
29
+ const schedules = new WeakMap<ReportingPolicy["digest"], CachedDigestSchedule>();
30
+
31
+
32
+ function localDigestMinute(at: number, timezone?: string): { day: string; clock: string } {
33
+ const key = timezone ?? "";
34
+ let formatter = localMinuteFormatters.get(key);
35
+ if (formatter === undefined) {
36
+ formatter = new Intl.DateTimeFormat("en-CA", {
37
+ timeZone: timezone,
38
+ year: "numeric",
39
+ month: "2-digit",
40
+ day: "2-digit",
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ hourCycle: "h23",
44
+ });
45
+ localMinuteFormatters.set(key, formatter);
46
+ }
47
+ const parts = formatter.formatToParts(new Date(at));
48
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
49
+ parts.find((part) => part.type === type)?.value ?? "00";
50
+ return {
51
+ day: `${get("year")}-${get("month")}-${get("day")}`,
52
+ clock: `${get("hour")}:${get("minute")}`,
53
+ };
23
54
  }
24
55
 
25
- /** `HH:MM` wall-clock in the zone, 24h and zero-padded. */
26
- function localClockAt(at: number, timezone?: string): string {
27
- return new Intl.DateTimeFormat("en-GB", {
28
- timeZone: timezone,
29
- hour: "2-digit",
30
- minute: "2-digit",
31
- hourCycle: "h23",
32
- }).format(new Date(at));
56
+ /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
57
+ export function localDayKey(at: number, timezone?: string): string {
58
+ return localDigestMinute(at, timezone).day;
33
59
  }
34
60
 
35
61
  /**
@@ -51,9 +77,51 @@ export function digestDue(
51
77
  if (cadence === "none") return false;
52
78
  if (cadence === "per-tick") return true;
53
79
  // daily
54
- if (at === undefined) {
55
- return lastDigestDayKey !== localDayKey(now, timezone);
80
+ const local = localDigestMinute(now, timezone);
81
+ if (at === undefined) return lastDigestDayKey !== local.day;
82
+ if (lastDigestDayKey === local.day) return false;
83
+ return local.clock >= at;
84
+ }
85
+
86
+ /** Mechanical status for the next digest opportunity, using the same
87
+ * predicate that gates report submission. Minute scanning deliberately keeps
88
+ * skipped/repeated DST wall-clock times on the runtime's real timeline. */
89
+ export function digestScheduleState(
90
+ policy: Pick<ReportingPolicy, "digest">,
91
+ lastDigestDayKey: string | undefined,
92
+ now: number,
93
+ ): DigestScheduleState {
94
+ if (policy.digest.cadence === "none") return { mode: "disabled" };
95
+ if (policy.digest.cadence === "per-tick") return { mode: "per-tick" };
96
+
97
+ const timezone =
98
+ policy.digest.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
99
+ if (digestDue(policy, lastDigestDayKey, now)) return { mode: "due", timezone };
100
+
101
+ const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
102
+ const cached = schedules.get(policy.digest);
103
+ if (
104
+ cached !== undefined &&
105
+ cached.lastDigestDayKey === lastDigestDayKey &&
106
+ from >= cached.from &&
107
+ from < cached.until
108
+ ) {
109
+ return cached.state;
56
110
  }
57
- if (lastDigestDayKey === localDayKey(now, timezone)) return false;
58
- return localClockAt(now, timezone) >= at;
59
- }
111
+
112
+ const end = now + NEXT_DIGEST_HORIZON_MS;
113
+ let cursor = from + MINUTE_MS;
114
+ while (cursor <= end && !digestDue(policy, lastDigestDayKey, cursor)) cursor += MINUTE_MS;
115
+ const state: Extract<DigestScheduleState, { mode: "scheduled" }> = {
116
+ mode: "scheduled",
117
+ timezone,
118
+ ...(cursor > end ? {} : { nextAt: cursor }),
119
+ };
120
+ schedules.set(policy.digest, {
121
+ lastDigestDayKey,
122
+ from,
123
+ until: cursor,
124
+ state,
125
+ });
126
+ return state;
127
+ }
package/src/escalate.ts CHANGED
@@ -20,11 +20,12 @@
20
20
  * those strings end up in daemon logs and, on the fallback path, in a public
21
21
  * issue comment.
22
22
  */
23
-
23
+ import { createHash } from "node:crypto";
24
24
  import { readFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { join } from "node:path";
27
27
 
28
+ import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
28
29
  import type { OrchestratorHandle } from "./orchestrator.ts";
29
30
  import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
30
31
 
@@ -137,13 +138,20 @@ export function formatEscalation(e: Escalation, project: string): string {
137
138
  * still escalate, just to an issue comment.
138
139
  */
139
140
  export function createEscalator(
140
- p: ProjectConfig,
141
+ source: ProjectConfig | (() => ProjectConfig),
141
142
  tracker: Tracker,
142
143
  store: Store,
143
144
  orchestrator?: OrchestratorHandle,
145
+ now: () => number = Date.now,
146
+ deliveryAllowed: () => boolean = () => true,
144
147
  ): Escalator {
148
+ const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
145
149
  return {
146
150
  async escalate(e: Escalation): Promise<void> {
151
+ // The daemon's provider resolves the config reloaded at the latest tick
152
+ // boundary. Capture one snapshot for this delivery, including late
153
+ // settlement callbacks, so a mid-call edit cannot split its policy.
154
+ const p = currentProject();
147
155
  // Stable across daemon restarts: same project, issue, tier and summary is
148
156
  // the same event, however many times the loop rediscovers it.
149
157
  const key = `${p.name}:${e.issue}:${e.tier}:${e.summary}`;
@@ -210,26 +218,45 @@ export function createEscalator(
210
218
  }
211
219
  }
212
220
 
221
+ if (e.tier === 2) {
222
+ const category = e.category ?? "tier2";
223
+ const at = now();
224
+ let disposition: InterruptDisposition;
225
+ if (!deliveryAllowed()) {
226
+ disposition = "availability";
227
+ } else if (e.urgent) {
228
+ // Urgency may bypass category batching when the digest loop itself is
229
+ // broken (#246), but it cannot invent an out-of-hours bypass the
230
+ // operator did not configure (#273).
231
+ const window = p.reporting?.availability;
232
+ disposition =
233
+ window !== undefined &&
234
+ !availabilityOpen(window, at) &&
235
+ !window.bypass.includes(category)
236
+ ? "availability"
237
+ : "interrupt";
238
+ } else {
239
+ disposition = interruptDisposition(p.reporting, category, at);
240
+ }
241
+ if (disposition !== "interrupt") {
242
+ store.addHeldNotice({
243
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
244
+ project: p.name,
245
+ category,
246
+ summary: e.summary,
247
+ detail: text,
248
+ createdAt: at,
249
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
250
+ ...(e.urgent === true ? { urgent: true } : {}),
251
+ });
252
+ store.markNotified(key);
253
+ return;
254
+ }
255
+ }
256
+
213
257
  if (e.tier === 2 && chatId) {
214
258
  const token = readTelegramToken();
215
259
  if (token) {
216
- // The digest can own deferred delivery only while its orchestrator
217
- // loop is alive. An urgent escalation says that loop is the failed
218
- // component, so waiting for its digest would park the only warning
219
- // behind the failure it reports (#246).
220
- const category = e.category ?? "tier2";
221
- const interruptOn = p.reporting?.interruptOn;
222
- if (!e.urgent && interruptOn !== undefined && !interruptOn.includes(category)) {
223
- store.addHeldNotice({
224
- project: p.name,
225
- category,
226
- summary: e.summary,
227
- detail: text,
228
- createdAt: Date.now(),
229
- });
230
- store.markNotified(key);
231
- return;
232
- }
233
260
  // A send failure throws: `markNotified` stays uncalled so the next
234
261
  // poll retries instead of writing the event off as delivered. No
235
262
  // backoff in here — the dispatcher tick *is* the retry, and an
package/src/fleet.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
+ import { formatZonedMinute } from "./availability.ts";
30
31
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
32
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
32
33
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
@@ -36,9 +37,9 @@ import { renderBriefForProject } from "./setup.ts";
36
37
  import type { ProjectConfig, Store } from "./types.ts";
37
38
  import { settlementFlagSummary } from "./diff-flags.ts";
38
39
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
39
- import { formatOpenReports } from "./reports.ts";
40
+ import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
40
41
  import {
41
- formatBaseChecks,
42
+ formatBaseHealth,
42
43
  formatDispatchSummary,
43
44
  formatReleaseGrants,
44
45
  formatSalvagedRuns,
@@ -1060,12 +1061,41 @@ export function formatFleetStatus(
1060
1061
  ].join("\n");
1061
1062
  }
1062
1063
 
1064
+ function formatAvailabilityStatus(s: StatusSnapshot): string[] {
1065
+ const availability = s.availability;
1066
+ if (availability === undefined) return [];
1067
+ if (availability.mode === "always") {
1068
+ return ["availability 24-hour interrupts (no weekly window)"];
1069
+ }
1070
+ const mode =
1071
+ availability.nextTransitionAt === undefined || availability.timezone === undefined
1072
+ ? `${availability.mode}; next transition could not be calculated`
1073
+ : `${availability.mode} until ${formatZonedMinute(availability.nextTransitionAt, availability.timezone)}`;
1074
+ const bypass = availability.bypass.length === 0 ? "none" : availability.bypass.join(", ");
1075
+ return [`availability ${mode}; quiet-hours bypass ${bypass}`];
1076
+ }
1077
+
1078
+ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
1079
+ const schedule = s.digestSchedule;
1080
+ if (schedule === undefined) return [];
1081
+ if (schedule.mode === "disabled") return ["next digest disabled"];
1082
+ if (schedule.mode === "per-tick") return ["next digest every tick"];
1083
+ if (schedule.mode === "due") return ["next digest due now"];
1084
+ return [
1085
+ schedule.nextAt === undefined
1086
+ ? "next digest could not be calculated"
1087
+ : `next digest ${formatZonedMinute(schedule.nextAt, schedule.timezone)}`,
1088
+ ];
1089
+ }
1090
+
1063
1091
  function formatProjectBody(s: StatusSnapshot): string {
1064
1092
  const lines = [
1065
1093
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
1066
1094
  ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
1067
1095
  `config ${s.configPath}`,
1068
1096
  `state ${s.stateDir}`,
1097
+ ...formatAvailabilityStatus(s),
1098
+ ...formatDigestScheduleStatus(s),
1069
1099
  "",
1070
1100
  "caps",
1071
1101
  ` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
@@ -1140,9 +1170,10 @@ function formatProjectBody(s: StatusSnapshot): string {
1140
1170
  if (flagged !== undefined) lines.push(` ${flagged}`);
1141
1171
  }
1142
1172
  }
1143
- lines.push(...formatBaseChecks(s.baseChecks));
1173
+ lines.push(...formatBaseHealth(s.baseHealth));
1144
1174
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1145
1175
  lines.push(...formatOpenReports(s.openReports));
1176
+ lines.push(...formatDigestBacklog(s.digestBacklog));
1146
1177
  if (s.liveWorkers > 0) {
1147
1178
  lines.push(
1148
1179
  "",