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.
- package/README.md +214 -66
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/briefs/orchestrator.md +63 -26
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +122 -14
- package/src/config.ts +113 -5
- package/src/daemon.ts +218 -32
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/fleet.ts +34 -3
- package/src/orchestrator-tick.ts +437 -20
- package/src/plugin.ts +138 -12
- package/src/reports.ts +202 -5
- package/src/setup.ts +193 -33
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +151 -12
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +8 -9
- package/src/worker.ts +18 -6
package/src/digest-schedule.ts
CHANGED
|
@@ -9,27 +9,53 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ReportingPolicy } from "./types.ts";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
/** `
|
|
26
|
-
function
|
|
27
|
-
return
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(...
|
|
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
|
"",
|