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/daemon.ts
CHANGED
|
@@ -18,11 +18,13 @@ import {
|
|
|
18
18
|
resolveReleaseGrants,
|
|
19
19
|
stateDir,
|
|
20
20
|
} from "./config.ts";
|
|
21
|
+
import { availabilityState, type AvailabilityState } from "./availability.ts";
|
|
21
22
|
import {
|
|
22
23
|
analyseSettlement,
|
|
23
24
|
formatSettlementFlags,
|
|
24
25
|
settlementFlagSummary,
|
|
25
26
|
} from "./diff-flags.ts";
|
|
27
|
+
import { digestScheduleState, type DigestScheduleState } from "./digest-schedule.ts";
|
|
26
28
|
import { createEscalator, escalationIssueRef } from "./escalate.ts";
|
|
27
29
|
import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
28
30
|
import { graphHint } from "./graph.ts";
|
|
@@ -30,7 +32,11 @@ import { livingDaemon } from "./lifecycle.ts";
|
|
|
30
32
|
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
31
33
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
32
34
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
createReportOutbox,
|
|
37
|
+
enqueueAvailableHeldNotices,
|
|
38
|
+
formatOpenReports,
|
|
39
|
+
} from "./reports.ts";
|
|
34
40
|
import {
|
|
35
41
|
recordReleaseBlock,
|
|
36
42
|
type ReleaseBlockContext,
|
|
@@ -42,11 +48,13 @@ import { classifyRun, providerCreditRefusal, providerTransientFault, type Classi
|
|
|
42
48
|
import { projectLabels } from "./label-projection.ts";
|
|
43
49
|
import { dbPath, LIVE_STATES, openStore, utcDay } from "./store.ts";
|
|
44
50
|
import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
|
|
45
|
-
import { RELEASE_SHAPES } from "./types.ts";
|
|
51
|
+
import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES } from "./types.ts";
|
|
46
52
|
import type {
|
|
53
|
+
BaseHealth,
|
|
47
54
|
AdmissionHoldReason,
|
|
48
55
|
Caps,
|
|
49
56
|
DispatchSummary,
|
|
57
|
+
DigestBacklog,
|
|
50
58
|
Escalation,
|
|
51
59
|
IssueSnapshot,
|
|
52
60
|
MergedPrInfo,
|
|
@@ -167,6 +175,9 @@ interface Deps {
|
|
|
167
175
|
caps: Caps;
|
|
168
176
|
tracker: Tracker;
|
|
169
177
|
store: Store;
|
|
178
|
+
/** False after a live config reload fails; autonomous delivery then holds
|
|
179
|
+
* fail-closed until a later tick validates the config again. */
|
|
180
|
+
deliveryPolicyValid?: boolean;
|
|
170
181
|
/** Provider-reported plan allowance, cached with a TTL. Resolved once at
|
|
171
182
|
* startup like every other dep so a tick cannot swap its own meter. */
|
|
172
183
|
usage: UsageSource;
|
|
@@ -1402,12 +1413,15 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
|
|
|
1402
1413
|
});
|
|
1403
1414
|
if (await settleStopBeforeSession()) return;
|
|
1404
1415
|
|
|
1416
|
+
const repoSlug = githubRepo(r.repo.cloneUrl);
|
|
1417
|
+
|
|
1405
1418
|
let result: WorkerResult;
|
|
1406
1419
|
try {
|
|
1407
1420
|
result = await runWorker({
|
|
1408
1421
|
brief,
|
|
1409
1422
|
cwd: worktreePath,
|
|
1410
1423
|
caps,
|
|
1424
|
+
...(repoSlug === undefined ? {} : { repoSlug }),
|
|
1411
1425
|
maxTurns: () => turnLimit?.maxTurns() ?? maxTurns,
|
|
1412
1426
|
onPauseControl: (control) => {
|
|
1413
1427
|
workerSessionInstalled = true;
|
|
@@ -1804,12 +1818,12 @@ export interface Settlement {
|
|
|
1804
1818
|
*
|
|
1805
1819
|
* - `merged` — the work landed. That is what `merged` was reserved for.
|
|
1806
1820
|
* - `closed` — a human read the work and said no. Leaving it `pushed-green`
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1821
|
+
* forever is a lie; `failed` records that it did not land and releases the
|
|
1822
|
+
* busy guard, so an issue a human re-queues can be attempted again. A row
|
|
1823
|
+
* that had reached `pushed-green` or `pushed-pending` is classified
|
|
1824
|
+
* `returned-for-revision` at settlement. A review decision asks for another
|
|
1825
|
+
* implementation pass, not a failure, so it consumes the continuation budget
|
|
1826
|
+
* instead of the failed-attempt budget.
|
|
1813
1827
|
* - `open`, and undefined — nothing changes. Undefined is "could not tell": a
|
|
1814
1828
|
* flaky network, a revoked token, a deleted PR. Settling on it would record a
|
|
1815
1829
|
* merge that never happened, and the next tick asks again for free. An
|
|
@@ -1944,15 +1958,24 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
|
|
|
1944
1958
|
|
|
1945
1959
|
let workflows;
|
|
1946
1960
|
try {
|
|
1947
|
-
workflows = await d.tracker.workflowRunsAt(repoIdentity, run.mergeSha
|
|
1961
|
+
workflows = await d.tracker.workflowRunsAt(repoIdentity, run.mergeSha, {
|
|
1962
|
+
event: "push",
|
|
1963
|
+
branch: run.baseRef,
|
|
1964
|
+
});
|
|
1948
1965
|
} catch (err) {
|
|
1949
1966
|
log(`#${run.issue} base check unavailable (${errText(err)}) — retrying next tick`);
|
|
1950
1967
|
continue;
|
|
1951
1968
|
}
|
|
1952
|
-
if (workflows === undefined
|
|
1969
|
+
if (workflows === undefined) {
|
|
1953
1970
|
log(`#${run.issue} base check unavailable for ${run.mergeSha} — retrying next tick`);
|
|
1954
1971
|
continue;
|
|
1955
1972
|
}
|
|
1973
|
+
if (workflows.length === 0) {
|
|
1974
|
+
log(
|
|
1975
|
+
`#${run.issue} base check: no push-triggered run yet for ${run.mergeSha} — retrying next tick`,
|
|
1976
|
+
);
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1956
1979
|
if (workflows.some((workflow) => workflow.status !== "completed")) continue;
|
|
1957
1980
|
|
|
1958
1981
|
const failed = workflows.find(
|
|
@@ -2016,6 +2039,110 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
|
|
|
2016
2039
|
}
|
|
2017
2040
|
}
|
|
2018
2041
|
|
|
2042
|
+
/**
|
|
2043
|
+
* Refresh current base-branch health at each recently merged repository's live
|
|
2044
|
+
* head. This is status and release-gate evidence only; the per-merge audit
|
|
2045
|
+
* above remains the sole path that attributes and escalates a regression.
|
|
2046
|
+
*/
|
|
2047
|
+
export async function watchBaseHealth(
|
|
2048
|
+
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
2049
|
+
): Promise<void> {
|
|
2050
|
+
const now = Date.now();
|
|
2051
|
+
const previousByRepo = new Map(
|
|
2052
|
+
d.store.baseHealth(d.project.name).map((row) => [row.repo, row] as const),
|
|
2053
|
+
);
|
|
2054
|
+
for (const { repo, baseRef } of d.store.mergedRepoBranches(
|
|
2055
|
+
d.project.name,
|
|
2056
|
+
now - BASE_STATUS_WINDOW_MS,
|
|
2057
|
+
)) {
|
|
2058
|
+
const target = d.project.routing.repos[repo];
|
|
2059
|
+
if (target === undefined) {
|
|
2060
|
+
log(`base health skipped: routed repository ${repo} is no longer configured`);
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
2063
|
+
const identity = githubRepo(target.cloneUrl);
|
|
2064
|
+
if (identity === undefined) {
|
|
2065
|
+
log(`base health skipped: routed repository ${repo} has no GitHub identity`);
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
const branch = baseRef ?? target.defaultBranch;
|
|
2069
|
+
|
|
2070
|
+
let head: string | undefined;
|
|
2071
|
+
try {
|
|
2072
|
+
head = await d.tracker.branchHead(identity, branch);
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
log(`base ${repo}/${branch} head unavailable (${errText(err)}) — keeping previous health`);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
if (head === undefined) {
|
|
2078
|
+
log(`base ${repo}/${branch} head unavailable — keeping previous health`);
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
const previous = previousByRepo.get(repo);
|
|
2083
|
+
if (
|
|
2084
|
+
previous?.branch === branch &&
|
|
2085
|
+
previous.headSha === head &&
|
|
2086
|
+
(previous.verdict === "green" || previous.verdict === "red")
|
|
2087
|
+
) {
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
let runs;
|
|
2092
|
+
try {
|
|
2093
|
+
runs = await d.tracker.workflowRunsAt(identity, head, { event: "push", branch });
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
log(`base ${repo}/${branch} workflows unavailable (${errText(err)}) — keeping previous health`);
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
if (runs === undefined) {
|
|
2099
|
+
log(`base ${repo}/${branch} workflows unavailable — keeping previous health`);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
let verdict: BaseHealth["verdict"];
|
|
2104
|
+
let detail: string | undefined;
|
|
2105
|
+
if (runs.length === 0) {
|
|
2106
|
+
verdict = "unknown";
|
|
2107
|
+
detail = `no push-triggered workflow run for ${head.slice(0, 8)}`;
|
|
2108
|
+
} else if (runs.some((run) => run.status !== "completed")) {
|
|
2109
|
+
verdict = "pending";
|
|
2110
|
+
} else {
|
|
2111
|
+
const failed = runs.find(
|
|
2112
|
+
(run) =>
|
|
2113
|
+
run.conclusion !== undefined &&
|
|
2114
|
+
FAILING_WORKFLOW_CONCLUSIONS.has(run.conclusion),
|
|
2115
|
+
);
|
|
2116
|
+
if (failed !== undefined) {
|
|
2117
|
+
verdict = "red";
|
|
2118
|
+
detail = `${failed.name} failed at ${head.slice(0, 8)} — ${failed.url}`;
|
|
2119
|
+
} else if (
|
|
2120
|
+
runs.some(
|
|
2121
|
+
(run) =>
|
|
2122
|
+
run.conclusion === undefined ||
|
|
2123
|
+
!SUCCESSFUL_WORKFLOW_CONCLUSIONS.has(run.conclusion),
|
|
2124
|
+
)
|
|
2125
|
+
) {
|
|
2126
|
+
verdict = "pending";
|
|
2127
|
+
} else {
|
|
2128
|
+
verdict = "green";
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const health: BaseHealth = {
|
|
2133
|
+
repo,
|
|
2134
|
+
branch,
|
|
2135
|
+
headSha: head,
|
|
2136
|
+
verdict,
|
|
2137
|
+
runsCount: runs.length,
|
|
2138
|
+
checkedAt: now,
|
|
2139
|
+
...(detail === undefined ? {} : { detail }),
|
|
2140
|
+
};
|
|
2141
|
+
d.store.upsertBaseHealth(d.project.name, health);
|
|
2142
|
+
previousByRepo.set(repo, health);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2019
2146
|
export async function settlePushedGreen(
|
|
2020
2147
|
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
2021
2148
|
): Promise<void> {
|
|
@@ -2080,7 +2207,11 @@ export async function settlePushedGreen(
|
|
|
2080
2207
|
baseCheck: "pending",
|
|
2081
2208
|
}),
|
|
2082
2209
|
};
|
|
2083
|
-
if (settlement.state === "failed")
|
|
2210
|
+
if (settlement.state === "failed") {
|
|
2211
|
+
patch.lastError = settlement.reason;
|
|
2212
|
+
patch.failureClass = "returned-for-revision";
|
|
2213
|
+
patch.recoveryAction = "none";
|
|
2214
|
+
}
|
|
2084
2215
|
store.updateRun(run.id, patch);
|
|
2085
2216
|
log(`#${run.issue} settled: ${settlement.reason}`);
|
|
2086
2217
|
continue;
|
|
@@ -2823,8 +2954,26 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2823
2954
|
}
|
|
2824
2955
|
d.project = fresh;
|
|
2825
2956
|
d.caps = freshCaps;
|
|
2957
|
+
d.deliveryPolicyValid = true;
|
|
2826
2958
|
} catch (err) {
|
|
2827
|
-
log(`config reload failed (${errText(err)}) —
|
|
2959
|
+
log(`config reload failed (${errText(err)}) — retaining boot values but blocking autonomous delivery`);
|
|
2960
|
+
d.deliveryPolicyValid = false;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// Availability-held notices are already durable. Once the freshly reloaded
|
|
2964
|
+
// policy opens (or newly allows their category), atomically hand a bounded
|
|
2965
|
+
// batch to the report outbox. Pauses do not suppress delivery.
|
|
2966
|
+
if (d.deliveryPolicyValid !== false) {
|
|
2967
|
+
try {
|
|
2968
|
+
const catchUp = enqueueAvailableHeldNotices(d.project, d.store, Date.now());
|
|
2969
|
+
if (catchUp !== undefined && !catchUp.deduped) {
|
|
2970
|
+
log(`availability catch-up ${catchUp.report.id} queued for ${d.project.name}`);
|
|
2971
|
+
}
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
// The daily digest may have claimed the same rows from another process
|
|
2974
|
+
// between selection and association. Either way the ledger still owns them.
|
|
2975
|
+
log(`availability catch-up handoff deferred (${errText(err)}) — retrying next tick`);
|
|
2976
|
+
}
|
|
2828
2977
|
}
|
|
2829
2978
|
|
|
2830
2979
|
// Before the pause check, deliberately. This one is not about dispatch: the
|
|
@@ -2844,6 +2993,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2844
2993
|
} catch (err) {
|
|
2845
2994
|
log(`base-branch check sweep failed: ${errText(err)}`);
|
|
2846
2995
|
}
|
|
2996
|
+
try {
|
|
2997
|
+
await watchBaseHealth(d);
|
|
2998
|
+
} catch (err) {
|
|
2999
|
+
log(`current base-health sweep failed: ${errText(err)}`);
|
|
3000
|
+
}
|
|
2847
3001
|
try {
|
|
2848
3002
|
await adoptSalvagedPrs(d);
|
|
2849
3003
|
} catch (err) {
|
|
@@ -3367,6 +3521,10 @@ export interface StatusSnapshot {
|
|
|
3367
3521
|
* reading like a mistake (#220).
|
|
3368
3522
|
*/
|
|
3369
3523
|
pauseReason?: string;
|
|
3524
|
+
/** Mechanical operator availability at the moment this snapshot was read. */
|
|
3525
|
+
availability?: AvailabilityState;
|
|
3526
|
+
/** Next digest opportunity under the same predicate that gates submission. */
|
|
3527
|
+
digestSchedule?: DigestScheduleState;
|
|
3370
3528
|
caps: Caps;
|
|
3371
3529
|
/**
|
|
3372
3530
|
* The effective per-shape release grants. On the snapshot rather than re-read
|
|
@@ -3386,6 +3544,9 @@ export interface StatusSnapshot {
|
|
|
3386
3544
|
* unknown outcome, or written off. An empty list is the only honest way to
|
|
3387
3545
|
* say "everything authored this cycle actually went out" (#123). */
|
|
3388
3546
|
openReports: ReportRecord[];
|
|
3547
|
+
/** Ordinary outcomes and deferred escalations not yet associated with an
|
|
3548
|
+
* accepted digest report. */
|
|
3549
|
+
digestBacklog: DigestBacklog;
|
|
3389
3550
|
/**
|
|
3390
3551
|
* The most recent conductor-verb calls and how the daemon decided them
|
|
3391
3552
|
* (#126). On `status` rather than only behind `omp-conductor ledger` because
|
|
@@ -3393,8 +3554,8 @@ export interface StatusSnapshot {
|
|
|
3393
3554
|
* config does not let it, and an operator who has to know to go looking is an
|
|
3394
3555
|
* operator who finds out from the tracker instead.
|
|
3395
3556
|
*/
|
|
3396
|
-
/**
|
|
3397
|
-
|
|
3557
|
+
/** Current live-head push-workflow verdict per recently merged repository. */
|
|
3558
|
+
baseHealth: BaseHealth[];
|
|
3398
3559
|
verbLedger: VerbLedgerEntry[];
|
|
3399
3560
|
/** Runs backed by a worker process — the number capacity compares against. */
|
|
3400
3561
|
liveWorkers: number;
|
|
@@ -3441,6 +3602,10 @@ export function statusSnapshotFromStore(
|
|
|
3441
3602
|
store: Store,
|
|
3442
3603
|
planUsage?: PlanUsageStatus,
|
|
3443
3604
|
): StatusSnapshot {
|
|
3605
|
+
const now = Date.now();
|
|
3606
|
+
const lastDigestKey = store.lastDigestDedupeKey(p.name);
|
|
3607
|
+
const lastDigestDay =
|
|
3608
|
+
lastDigestKey === undefined ? undefined : lastDigestKey.slice("digest:".length);
|
|
3444
3609
|
const since = startOfToday();
|
|
3445
3610
|
const dispatch = store.latestDispatch(p.name);
|
|
3446
3611
|
const labelOpsPending = store.countPendingLabelOps(p.name);
|
|
@@ -3454,12 +3619,15 @@ export function statusSnapshotFromStore(
|
|
|
3454
3619
|
stateDir: stateDir(),
|
|
3455
3620
|
paused: isPaused(),
|
|
3456
3621
|
...(reason === undefined ? {} : { pauseReason: reason }),
|
|
3622
|
+
availability: availabilityState(p.reporting, now),
|
|
3623
|
+
digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
|
|
3457
3624
|
caps,
|
|
3458
3625
|
releaseGrants: resolveReleaseGrants(p),
|
|
3459
3626
|
activeRuns: store.activeRuns(p.name),
|
|
3460
3627
|
salvagedRuns: store.salvagedRuns(p.name),
|
|
3461
3628
|
turnOverrides: store.listTurnOverrides(p.name),
|
|
3462
3629
|
openReports: store.openReports(p.name),
|
|
3630
|
+
digestBacklog: store.digestBacklog(p.name),
|
|
3463
3631
|
verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
|
|
3464
3632
|
liveWorkers: store.liveRuns(p.name).length,
|
|
3465
3633
|
runsToday: store.runsStartedSince(p.name, since),
|
|
@@ -3468,12 +3636,12 @@ export function statusSnapshotFromStore(
|
|
|
3468
3636
|
...(planUsage === undefined ? {} : { planUsage }),
|
|
3469
3637
|
// Written by the tracker's hooks rather than polled, so the renderer does
|
|
3470
3638
|
// not re-read GitHub to know it is being refused (#198).
|
|
3471
|
-
ghRefusals: store.ghRefusalsSince?.(
|
|
3639
|
+
ghRefusals: store.ghRefusalsSince?.(now - 5 * 60_000),
|
|
3472
3640
|
ghCallsToday: store.ghCallsToday?.(utcDay()),
|
|
3473
3641
|
...(labelOpsPending === 0 || oldestLabelOpAt === undefined
|
|
3474
3642
|
? {}
|
|
3475
|
-
: { labelOps: { pending: labelOpsPending, oldestAgeMs:
|
|
3476
|
-
|
|
3643
|
+
: { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
|
|
3644
|
+
baseHealth: store.baseHealth(p.name),
|
|
3477
3645
|
};
|
|
3478
3646
|
}
|
|
3479
3647
|
|
|
@@ -3556,18 +3724,22 @@ export function formatReleaseGrants(grants: ResolvedGrants): string[] {
|
|
|
3556
3724
|
...RELEASE_SHAPES.map((shape) => ` ${shape.padEnd(19)}${grants[shape]}`),
|
|
3557
3725
|
];
|
|
3558
3726
|
}
|
|
3559
|
-
export function
|
|
3560
|
-
return
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3727
|
+
export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
|
|
3728
|
+
return rows.map((row) => {
|
|
3729
|
+
const head = row.headSha.slice(0, 8);
|
|
3730
|
+
if (row.verdict === "green") {
|
|
3731
|
+
return `base ${row.repo}/${row.branch} green (${row.runsCount} run(s)) at ${head}`;
|
|
3732
|
+
}
|
|
3733
|
+
if (row.verdict === "red") {
|
|
3734
|
+
return `base ${row.repo}/${row.branch} RED — ${row.detail ?? `workflow failed at ${head}`}`;
|
|
3735
|
+
}
|
|
3736
|
+
if (row.verdict === "pending") {
|
|
3737
|
+
return `base ${row.repo}/${row.branch} pending (${row.runsCount} run(s)) at ${head}`;
|
|
3738
|
+
}
|
|
3739
|
+
return (
|
|
3740
|
+
`base ${row.repo}/${row.branch} unknown — ` +
|
|
3741
|
+
(row.detail ?? `no push-triggered workflow run for ${head}`)
|
|
3742
|
+
);
|
|
3571
3743
|
});
|
|
3572
3744
|
}
|
|
3573
3745
|
|
|
@@ -3618,7 +3790,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
3618
3790
|
if (flagged !== undefined) lines.push(` ${flagged}`);
|
|
3619
3791
|
}
|
|
3620
3792
|
}
|
|
3621
|
-
lines.push(...
|
|
3793
|
+
lines.push(...formatBaseHealth(s.baseHealth));
|
|
3622
3794
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
3623
3795
|
lines.push(...formatOpenReports(s.openReports));
|
|
3624
3796
|
lines.push(...formatVerbLedger(s.verbLedger));
|
|
@@ -4380,7 +4552,17 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4380
4552
|
}
|
|
4381
4553
|
}
|
|
4382
4554
|
|
|
4383
|
-
|
|
4555
|
+
let runtimeDeps: Deps | undefined;
|
|
4556
|
+
const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
|
|
4557
|
+
const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
|
|
4558
|
+
const escalator = createEscalator(
|
|
4559
|
+
currentProject,
|
|
4560
|
+
tracker,
|
|
4561
|
+
store,
|
|
4562
|
+
orchestrator,
|
|
4563
|
+
Date.now,
|
|
4564
|
+
deliveryPolicyValid,
|
|
4565
|
+
);
|
|
4384
4566
|
|
|
4385
4567
|
// Report delivery is the daemon's, not the model's (#123). Built beside the
|
|
4386
4568
|
// escalator because a report nobody can deliver pages through it, and driven
|
|
@@ -4388,10 +4570,11 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4388
4570
|
// but still owes its operator the report it was handed, and five minutes is a
|
|
4389
4571
|
// long time to sit on a page.
|
|
4390
4572
|
const outbox = createReportOutbox({
|
|
4391
|
-
project,
|
|
4573
|
+
project: currentProject,
|
|
4392
4574
|
store,
|
|
4393
4575
|
escalate: (e) => escalator.escalate(e),
|
|
4394
4576
|
log,
|
|
4577
|
+
deliveryAllowed: deliveryPolicyValid,
|
|
4395
4578
|
});
|
|
4396
4579
|
|
|
4397
4580
|
// Every row still `sending` when a daemon boots belonged to a process that is
|
|
@@ -4417,6 +4600,8 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4417
4600
|
caps,
|
|
4418
4601
|
tracker,
|
|
4419
4602
|
store,
|
|
4603
|
+
// Fail closed until the first tick re-reads and validates the live config.
|
|
4604
|
+
deliveryPolicyValid: false,
|
|
4420
4605
|
// Process-wide, so a `status` served off this daemon's own HTTP surface
|
|
4421
4606
|
// reuses the tick's reading instead of shelling out again.
|
|
4422
4607
|
usage: sharedUsageSource(),
|
|
@@ -4431,6 +4616,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
4431
4616
|
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
4432
4617
|
verbActions,
|
|
4433
4618
|
};
|
|
4619
|
+
runtimeDeps = d;
|
|
4434
4620
|
|
|
4435
4621
|
if (o.once) {
|
|
4436
4622
|
try {
|
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 {
|