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/README.md +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
package/src/diff-flags.ts
CHANGED
|
@@ -290,9 +290,19 @@ function expandBraces(token: string): string[] {
|
|
|
290
290
|
return alternatives.map((alt) => prefix + alt + suffix);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/** A trailing slash-separated extension list:
|
|
294
|
+
* `scripts/a.sh/.py` → [`scripts/a.sh`, `scripts/a.py`]. */
|
|
295
|
+
function expandExtensionAlternation(token: string): string[] {
|
|
296
|
+
const match = /^(.+?)\.([A-Za-z][A-Za-z0-9]{1,7})((?:\/\.[A-Za-z][A-Za-z0-9]{1,7})+)$/.exec(token);
|
|
297
|
+
if (match === null) return [token];
|
|
298
|
+
const [, base, first, rest] = match;
|
|
299
|
+
if (base === undefined || first === undefined || rest === undefined) return [token];
|
|
300
|
+
return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
293
303
|
/** Every path-shaped token on the report's `changed:` line. An absent line and
|
|
294
304
|
* a line naming nothing are the same answer: nothing was disclosed. */
|
|
295
|
-
|
|
305
|
+
function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
|
|
296
306
|
const line = CHANGED_LINE.exec(report)?.[1] ?? "";
|
|
297
307
|
const seen = new Set<string>();
|
|
298
308
|
// Split on whitespace and semicolons, and on commas *outside* a brace group:
|
|
@@ -306,13 +316,21 @@ export function claimedPaths(report: string): string[] {
|
|
|
306
316
|
// directions — sees plain paths: a brace token dies earlier at the
|
|
307
317
|
// CLAIMED_PATH filter if it is never opened up (#224).
|
|
308
318
|
for (const expanded of expandBraces(normalised)) {
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
const alternatives = expandExtensionAlternation(expanded);
|
|
320
|
+
for (const path of alternatives) {
|
|
321
|
+
if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
|
|
322
|
+
seen.add(path);
|
|
323
|
+
if (alternatives.length > 1) extensionAlternatives?.add(path);
|
|
324
|
+
}
|
|
311
325
|
}
|
|
312
326
|
}
|
|
313
327
|
return [...seen];
|
|
314
328
|
}
|
|
315
329
|
|
|
330
|
+
export function claimedPaths(report: string): string[] {
|
|
331
|
+
return parseClaimedPaths(report);
|
|
332
|
+
}
|
|
333
|
+
|
|
316
334
|
/**
|
|
317
335
|
* Whether one claim covers one path. Every rule here is deliberately generous,
|
|
318
336
|
* because each one that fails produces an omission flag on an honest report:
|
|
@@ -505,7 +523,8 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
|
|
|
505
523
|
|
|
506
524
|
function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
507
525
|
const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
|
|
508
|
-
const
|
|
526
|
+
const extensionAlternatives = new Set<string>();
|
|
527
|
+
const claims = parseClaimedPaths(audit.report, extensionAlternatives);
|
|
509
528
|
const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
|
|
510
529
|
// Coverage pools the current report with every prior attempt's disclosures:
|
|
511
530
|
// a file the final report no longer names was disclosed while the work was
|
|
@@ -553,12 +572,62 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
553
572
|
if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
|
|
554
573
|
continue;
|
|
555
574
|
}
|
|
575
|
+
if (extensionAlternatives.has(claim)) {
|
|
576
|
+
const stem = claim.slice(0, claim.lastIndexOf("."));
|
|
577
|
+
const matchedAlternative = claims.some(
|
|
578
|
+
(other) =>
|
|
579
|
+
other !== claim &&
|
|
580
|
+
extensionAlternatives.has(other) &&
|
|
581
|
+
other.slice(0, other.lastIndexOf(".")) === stem &&
|
|
582
|
+
audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
|
|
583
|
+
);
|
|
584
|
+
if (matchedAlternative) continue;
|
|
585
|
+
}
|
|
556
586
|
flags.push({
|
|
557
587
|
kind: "unmatched-claim",
|
|
558
588
|
file: claim,
|
|
559
589
|
detail: "named by the report's `changed:` line but not touched by the PR",
|
|
560
590
|
});
|
|
561
591
|
}
|
|
592
|
+
|
|
593
|
+
// A parser failure can otherwise accuse the report in both directions for
|
|
594
|
+
// the same file. Collapse only that self-refuting pair; unrelated findings
|
|
595
|
+
// remain intact.
|
|
596
|
+
const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
|
|
597
|
+
const removed = new Set<SettlementFlag>();
|
|
598
|
+
let exampleClaim = "";
|
|
599
|
+
let examplePath = "";
|
|
600
|
+
for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
|
|
601
|
+
const name = basename(undisclosed.file);
|
|
602
|
+
const extension = name.lastIndexOf(".");
|
|
603
|
+
const stem = extension > 0 ? name.slice(0, extension) : name;
|
|
604
|
+
if (stem.length < 3) continue;
|
|
605
|
+
for (const claim of unmatched) {
|
|
606
|
+
if (!claim.file.includes(stem)) continue;
|
|
607
|
+
removed.add(undisclosed);
|
|
608
|
+
removed.add(claim);
|
|
609
|
+
if (exampleClaim === "") {
|
|
610
|
+
exampleClaim = claim.file;
|
|
611
|
+
examplePath = undisclosed.file;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (removed.size === 0) return;
|
|
616
|
+
|
|
617
|
+
const kept = flags.filter((flag) => !removed.has(flag));
|
|
618
|
+
const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
|
|
619
|
+
flags.splice(
|
|
620
|
+
0,
|
|
621
|
+
flags.length,
|
|
622
|
+
...kept,
|
|
623
|
+
{
|
|
624
|
+
kind: "report-format-unparsed",
|
|
625
|
+
file: "(report)",
|
|
626
|
+
detail:
|
|
627
|
+
`${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
|
|
628
|
+
`file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
|
|
629
|
+
},
|
|
630
|
+
);
|
|
562
631
|
}
|
|
563
632
|
|
|
564
633
|
function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
@@ -745,6 +814,10 @@ export function formatSettlementFlags(
|
|
|
745
814
|
pooledHeading(diff.attempts),
|
|
746
815
|
];
|
|
747
816
|
for (const flag of flags.slice(0, RENDERED_FLAGS)) {
|
|
817
|
+
if (flag.kind === "pr-adopted") {
|
|
818
|
+
lines.push(` ${flag.kind} — ${flag.detail}`);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
748
821
|
lines.push(
|
|
749
822
|
` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
|
|
750
823
|
(flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
|
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
|
-
// A category this policy defers does not page now: it is held here so
|
|
217
|
-
// the digest is the delivery authority for it (#229). A missing
|
|
218
|
-
// `reporting` block means the default (page everything); an explicit
|
|
219
|
-
// list decides each escalation by its category, defaulting `tier2`.
|
|
220
|
-
const category = e.category ?? "tier2";
|
|
221
|
-
const interruptOn = p.reporting?.interruptOn;
|
|
222
|
-
if (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/failure-class.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface ClassifyFacts {
|
|
|
22
22
|
pr?: "open" | "merged" | "closed";
|
|
23
23
|
mergeable?: "conflicting" | "clean" | "unknown";
|
|
24
24
|
checks?: { name: string; state: string; link?: string }[];
|
|
25
|
+
/** Full session error recovered from the transcript. Kept as a fact so a
|
|
26
|
+
* classification retry does not lose an HTTP status the run row cannot store. */
|
|
27
|
+
sessionError?: { status?: number; message: string };
|
|
25
28
|
/** Tail (ANSI-stripped) of the first failed check's log, when one was
|
|
26
29
|
* reachable. Lets the table tell an infrastructure outage (#177) from a
|
|
27
30
|
* deterministic test failure by the log's own words. */
|
|
@@ -221,6 +224,8 @@ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
|
|
|
221
224
|
|
|
222
225
|
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
223
226
|
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
227
|
+
const providerError =
|
|
228
|
+
facts.sessionError ?? (run.lastError === undefined ? undefined : { message: run.lastError });
|
|
224
229
|
|
|
225
230
|
// The PR landed while the row says otherwise. Whatever else is true about this
|
|
226
231
|
// run, it succeeded, and the recovery is bookkeeping.
|
|
@@ -249,10 +254,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
249
254
|
// first request produces a turn-0 row with no artifacts, which
|
|
250
255
|
// `env-start-failure` would otherwise absorb and lose the cause.
|
|
251
256
|
if (run.state === "failed" || run.state === "killed") {
|
|
252
|
-
const credit =
|
|
253
|
-
run.lastError === undefined
|
|
254
|
-
? undefined
|
|
255
|
-
: providerCreditRefusal({ message: run.lastError });
|
|
257
|
+
const credit = providerError === undefined ? undefined : providerCreditRefusal(providerError);
|
|
256
258
|
if (credit !== undefined) {
|
|
257
259
|
return { cls: "provider-credit", recovery: "requeue", evidence: credit };
|
|
258
260
|
}
|
|
@@ -267,7 +269,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
267
269
|
// the same reason as the credit branch: a turn-0 stall would otherwise be
|
|
268
270
|
// absorbed by `env-start-failure` and lose its cause.
|
|
269
271
|
if (run.state === "failed" || run.state === "killed") {
|
|
270
|
-
const transient =
|
|
272
|
+
const transient = providerError === undefined ? undefined : providerTransientFault(providerError);
|
|
271
273
|
if (transient !== undefined) {
|
|
272
274
|
return { cls: "provider-transient", recovery: "requeue", evidence: transient };
|
|
273
275
|
}
|
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,8 +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 {
|
|
42
|
+
formatBaseHealth,
|
|
41
43
|
formatDispatchSummary,
|
|
42
44
|
formatReleaseGrants,
|
|
43
45
|
formatSalvagedRuns,
|
|
@@ -1026,7 +1028,7 @@ export function formatFleetStatus(
|
|
|
1026
1028
|
// a halt) answers "who stopped the fleet" without opening a file (#185). An
|
|
1027
1029
|
// unparseable sentinel — paused but with no datable line 1 — is itself news:
|
|
1028
1030
|
// it means a run admitted before an *unknown* pause cannot prove innocence
|
|
1029
|
-
// (#174), so
|
|
1031
|
+
// (#174), so completion mutations fail closed while release gates remain usable.
|
|
1030
1032
|
const dispatchLine =
|
|
1031
1033
|
layers.dispatch === "paused"
|
|
1032
1034
|
? (() => {
|
|
@@ -1036,7 +1038,7 @@ export function formatFleetStatus(
|
|
|
1036
1038
|
return `dispatch paused (source: ${prov.source}${reason})`;
|
|
1037
1039
|
}
|
|
1038
1040
|
return isPaused() && pausedAt() === undefined
|
|
1039
|
-
? "dispatch paused (unparseable sentinel —
|
|
1041
|
+
? "dispatch paused (unparseable sentinel — new work and completion mutations refused; release gates remain available)"
|
|
1040
1042
|
: "dispatch paused";
|
|
1041
1043
|
})()
|
|
1042
1044
|
: `dispatch ${layers.dispatch}`;
|
|
@@ -1059,12 +1061,41 @@ export function formatFleetStatus(
|
|
|
1059
1061
|
].join("\n");
|
|
1060
1062
|
}
|
|
1061
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
|
+
|
|
1062
1091
|
function formatProjectBody(s: StatusSnapshot): string {
|
|
1063
1092
|
const lines = [
|
|
1064
1093
|
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
1065
1094
|
...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
|
|
1066
1095
|
`config ${s.configPath}`,
|
|
1067
1096
|
`state ${s.stateDir}`,
|
|
1097
|
+
...formatAvailabilityStatus(s),
|
|
1098
|
+
...formatDigestScheduleStatus(s),
|
|
1068
1099
|
"",
|
|
1069
1100
|
"caps",
|
|
1070
1101
|
` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
|
|
@@ -1110,6 +1141,9 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1110
1141
|
})`,
|
|
1111
1142
|
]),
|
|
1112
1143
|
` new worker turns ${s.caps.workerMaxTurns}`,
|
|
1144
|
+
...s.turnOverrides.map(
|
|
1145
|
+
({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
|
|
1146
|
+
),
|
|
1113
1147
|
` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
|
|
1114
1148
|
` failed attempts ${s.caps.maxAttemptsPerIssue}`,
|
|
1115
1149
|
` continuations ${s.caps.maxContinuationsPerIssue}`,
|
|
@@ -1136,8 +1170,10 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1136
1170
|
if (flagged !== undefined) lines.push(` ${flagged}`);
|
|
1137
1171
|
}
|
|
1138
1172
|
}
|
|
1173
|
+
lines.push(...formatBaseHealth(s.baseHealth));
|
|
1139
1174
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
1140
1175
|
lines.push(...formatOpenReports(s.openReports));
|
|
1176
|
+
lines.push(...formatDigestBacklog(s.digestBacklog));
|
|
1141
1177
|
if (s.liveWorkers > 0) {
|
|
1142
1178
|
lines.push(
|
|
1143
1179
|
"",
|
package/src/omp.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
21
|
import { worktreeConfinement } from "./confinement.ts";
|
|
22
|
-
import { releasePolicyTripwire } from "./release-policy.ts";
|
|
22
|
+
import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
|
|
23
23
|
import type {
|
|
24
24
|
HostToParent,
|
|
25
25
|
ParentToHost,
|
|
@@ -158,7 +158,7 @@ export async function createLocalSession(opts: {
|
|
|
158
158
|
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
159
159
|
releaseGrants?: ResolvedGrants;
|
|
160
160
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
161
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
161
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
162
162
|
/**
|
|
163
163
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
164
164
|
*
|
|
@@ -393,7 +393,7 @@ export interface CreateSessionOptions {
|
|
|
393
393
|
resume?: boolean;
|
|
394
394
|
role: SessionRole;
|
|
395
395
|
releaseGrants?: ResolvedGrants;
|
|
396
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
396
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
397
397
|
|
|
398
398
|
/**
|
|
399
399
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -681,7 +681,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
681
681
|
break;
|
|
682
682
|
}
|
|
683
683
|
case "release-blocked":
|
|
684
|
-
opts.onReleaseBlocked?.(message.shape
|
|
684
|
+
opts.onReleaseBlocked?.(message.shape, {
|
|
685
|
+
tool: message.tool,
|
|
686
|
+
reason: message.reason,
|
|
687
|
+
args: message.args,
|
|
688
|
+
});
|
|
685
689
|
break;
|
|
686
690
|
}
|
|
687
691
|
}
|