omp-conductor 0.7.1 → 0.9.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 +60 -30
- package/package.json +1 -1
- package/src/backups.ts +46 -0
- package/src/board.ts +254 -38
- package/src/brief-upgrade.ts +1 -30
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +90 -29
- package/src/config.ts +24 -0
- package/src/daemon.ts +311 -131
- package/src/decisions.ts +67 -7
- package/src/diff-flags.ts +35 -6
- package/src/fleet.ts +65 -6
- package/src/label-projection.ts +93 -0
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/routing.ts +20 -0
- package/src/setup.ts +1 -1
- package/src/store.ts +254 -2
- package/src/tracker/github.ts +489 -80
- package/src/types.ts +85 -3
- package/src/unblock.ts +156 -20
- package/src/upgrade.ts +57 -7
- package/src/worktree.ts +23 -4
package/src/daemon.ts
CHANGED
|
@@ -32,12 +32,13 @@ import { startOrchestrator } from "./orchestrator.ts";
|
|
|
32
32
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
33
33
|
import { createReportOutbox, formatOpenReports } from "./reports.ts";
|
|
34
34
|
import { recordReleaseBlock } from "./release-policy.ts";
|
|
35
|
-
import { branchName, route } from "./routing.ts";
|
|
35
|
+
import { branchName, effectiveLabels, route } from "./routing.ts";
|
|
36
36
|
import type { Routed, UnroutableReason } from "./routing.ts";
|
|
37
|
-
import { evaluateDecisionConditions, probeNpmVersion } from "./decisions.ts";
|
|
37
|
+
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "./decisions.ts";
|
|
38
38
|
import { classifyRun, type ClassifyFacts } from "./failure-class.ts";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
39
|
+
import { projectLabels } from "./label-projection.ts";
|
|
40
|
+
import { dbPath, openStore, utcDay } from "./store.ts";
|
|
41
|
+
import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
|
|
41
42
|
import { RELEASE_SHAPES } from "./types.ts";
|
|
42
43
|
import type {
|
|
43
44
|
AdmissionHoldReason,
|
|
@@ -323,11 +324,45 @@ export function pausedAt(): number | undefined {
|
|
|
323
324
|
}
|
|
324
325
|
}
|
|
325
326
|
|
|
326
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Who paused the fleet and why, read from line 2 of the same sentinel
|
|
329
|
+
* {@link setPaused} writes — `undefined` when the fleet is not paused or the
|
|
330
|
+
* file has no (parseable) line 2. Provenance lives on its own line so line 1
|
|
331
|
+
* stays a pure ISO timestamp that {@link pausedAt} can `Date.parse`; the `armed
|
|
332
|
+
* <ISO> owner=<id>` marker `armTicks` writes is the same one-key-per-line
|
|
333
|
+
* precedent.
|
|
334
|
+
*/
|
|
335
|
+
export function pauseProvenance(): { source: string; reason?: string } | undefined {
|
|
336
|
+
const f = join(stateDir(), "paused");
|
|
337
|
+
if (!existsSync(f)) return undefined;
|
|
338
|
+
try {
|
|
339
|
+
const second = readFileSync(f, "utf8").split("\n")[1];
|
|
340
|
+
if (second === undefined) return undefined;
|
|
341
|
+
const match = /^source=(\S+)(?: reason="(.*)")?$/.exec(second.trim());
|
|
342
|
+
if (match === null) return undefined;
|
|
343
|
+
const source = match[1]!; // the regex guarantees group 1 on a match
|
|
344
|
+
const reason = match[2];
|
|
345
|
+
return { source, ...(reason === undefined ? {} : { reason }) };
|
|
346
|
+
} catch {
|
|
347
|
+
// Unreadable sentinel: no provenance to name, and the refuse-everything
|
|
348
|
+
// posture of an unknown pause is unchanged.
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function setPaused(v: boolean, why?: { source: string; reason?: string }): void {
|
|
327
354
|
const f = join(stateDir(), "paused");
|
|
328
355
|
if (v) {
|
|
329
356
|
mkdirSync(dirname(f), { recursive: true });
|
|
330
|
-
|
|
357
|
+
const line1 = `${new Date().toISOString()}\n`;
|
|
358
|
+
if (why === undefined) {
|
|
359
|
+
writeFileSync(f, line1);
|
|
360
|
+
} else {
|
|
361
|
+
// Quotes are stripped before embedding so a reason cannot break out of
|
|
362
|
+
// the `reason="..."` field of line 2.
|
|
363
|
+
const reason = why.reason === undefined ? "" : ` reason="${why.reason.replaceAll('"', "")}"`;
|
|
364
|
+
writeFileSync(f, `${line1}source=${why.source}${reason}\n`);
|
|
365
|
+
}
|
|
331
366
|
} else {
|
|
332
367
|
rmSync(f, { force: true });
|
|
333
368
|
}
|
|
@@ -481,13 +516,17 @@ function acceptanceCriteria(issue: ReadyIssue): string {
|
|
|
481
516
|
}
|
|
482
517
|
|
|
483
518
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* shape `isEligible` treats as fresh
|
|
519
|
+
* Record a state-label swap for projection (#201). The add enqueues before the
|
|
520
|
+
* remove — the reverse order would leave a window where the issue carries no
|
|
521
|
+
* state label at all, which is exactly the shape `isEligible` treats as fresh
|
|
522
|
+
* work. Synchronous: enqueueing is a local store write and cannot fail on the
|
|
523
|
+
* tracker; the projector applies the pair in order and retries on refusal.
|
|
487
524
|
*/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
525
|
+
function swapLabel(store: Store, projectName: string, issue: number, from: string, to: string): void {
|
|
526
|
+
store.enqueueLabelOps(projectName, [
|
|
527
|
+
{ issue, op: "add", label: to },
|
|
528
|
+
{ issue, op: "remove", label: from },
|
|
529
|
+
]);
|
|
491
530
|
}
|
|
492
531
|
|
|
493
532
|
/**
|
|
@@ -821,13 +860,18 @@ export async function verifyPushedGreenClaim(
|
|
|
821
860
|
*/
|
|
822
861
|
export async function collectSettlementFlags(
|
|
823
862
|
tracker: Pick<Tracker, "prDiff">,
|
|
824
|
-
claim: { prUrl?: string; report: string; issueText: string },
|
|
863
|
+
claim: { prUrl?: string; report: string; priorReports?: readonly string[]; issueText: string },
|
|
825
864
|
): Promise<{ flags: SettlementFlag[]; truncated: boolean } | undefined> {
|
|
826
865
|
if (claim.prUrl === undefined) return undefined;
|
|
827
866
|
const diff = await tracker.prDiff(claim.prUrl);
|
|
828
867
|
if (diff === undefined) return undefined;
|
|
829
868
|
return {
|
|
830
|
-
flags: analyseSettlement({
|
|
869
|
+
flags: analyseSettlement({
|
|
870
|
+
report: claim.report,
|
|
871
|
+
issueText: claim.issueText,
|
|
872
|
+
diff,
|
|
873
|
+
priorReports: claim.priorReports,
|
|
874
|
+
}),
|
|
831
875
|
truncated: diff.truncated,
|
|
832
876
|
};
|
|
833
877
|
}
|
|
@@ -904,12 +948,17 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
904
948
|
};
|
|
905
949
|
|
|
906
950
|
try {
|
|
907
|
-
// Claim on the
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
951
|
+
// Claim on the STORE first, before anything that can fail. The run row —
|
|
952
|
+
// not the label — is the crash-safe guard against double dispatch: rows
|
|
953
|
+
// are local, written before any network call, and the startup orphan
|
|
954
|
+
// sweep marks process-less claimed/running rows `orphaned`, so a daemon
|
|
955
|
+
// dying mid-claim leaves a row a human can triage instead of a label only
|
|
956
|
+
// the orchestrator may touch. The in-progress label is a write-behind
|
|
957
|
+
// projection of that row: enqueued here, flushed by the post-admission
|
|
958
|
+
// projection pass in the same tick on the healthy path, and — while
|
|
959
|
+
// pending — kept effective off the ready set by the routing overlay, so a
|
|
960
|
+
// crash between the row and its label cannot double-dispatch either
|
|
961
|
+
// (#201).
|
|
913
962
|
// Read before this attempt's own row exists, so `latestRun` still means the
|
|
914
963
|
// attempt whose work this one inherits.
|
|
915
964
|
const priorSalvage = store.latestRun(project.name, issue)?.salvageSha;
|
|
@@ -929,6 +978,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
929
978
|
startedAt: Date.now(),
|
|
930
979
|
});
|
|
931
980
|
const runId = run.id;
|
|
981
|
+
store.enqueueLabelOps(project.name, [{ issue, op: "add", label: inProgress }]);
|
|
982
|
+
claimed = true;
|
|
932
983
|
turnLimit = d.turnLimits.open(project.name, issue, runId, caps.workerMaxTurns);
|
|
933
984
|
|
|
934
985
|
// A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
|
|
@@ -1068,6 +1119,15 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1068
1119
|
? await collectSettlementFlags(tracker, {
|
|
1069
1120
|
prUrl: result.prUrl,
|
|
1070
1121
|
report: result.report,
|
|
1122
|
+
// Earlier attempts' reports pool as disclosure coverage (#199). The
|
|
1123
|
+
// current row has not settled yet, so this is exactly the prior set;
|
|
1124
|
+
// the strict `< attempt` guard keeps an already-persisted copy of
|
|
1125
|
+
// this attempt out just in case. Pre-#199 rows have NULL reports and
|
|
1126
|
+
// contribute nothing — today's behaviour.
|
|
1127
|
+
priorReports: store
|
|
1128
|
+
.attemptReports(project.name, issue)
|
|
1129
|
+
.filter((r) => r.attempt < attempt)
|
|
1130
|
+
.map((r) => r.report),
|
|
1071
1131
|
issueText: `${r.issue.title}\n${r.issue.body}`,
|
|
1072
1132
|
})
|
|
1073
1133
|
: undefined;
|
|
@@ -1076,7 +1136,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1076
1136
|
else if (audit.truncated) log(`#${issue} settlement audit read a truncated PR diff`);
|
|
1077
1137
|
}
|
|
1078
1138
|
const auditLines =
|
|
1079
|
-
audit === undefined
|
|
1139
|
+
audit === undefined
|
|
1140
|
+
? []
|
|
1141
|
+
: formatSettlementFlags(audit.flags, { truncated: audit.truncated, attempts: attempt });
|
|
1080
1142
|
|
|
1081
1143
|
const finalReport = [
|
|
1082
1144
|
...(verified.reason === undefined ? [] : [verified.reason, ""]),
|
|
@@ -1126,6 +1188,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1126
1188
|
prUrl: result.prUrl,
|
|
1127
1189
|
headSha: result.headSha,
|
|
1128
1190
|
sessionFile: result.sessionFile,
|
|
1191
|
+
// Every terminal state persists the worker's report, not just a green
|
|
1192
|
+
// push: the report of a killed attempt is exactly the one a later
|
|
1193
|
+
// continuation must pool its disclosures from (#199).
|
|
1194
|
+
report: result.report,
|
|
1129
1195
|
...(verified.reason === undefined ? {} : { lastError: verified.reason }),
|
|
1130
1196
|
...settlement?.patch,
|
|
1131
1197
|
...(audit === undefined || audit.flags.length === 0
|
|
@@ -1136,7 +1202,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1136
1202
|
const salvaged = settlement?.lines ?? [];
|
|
1137
1203
|
|
|
1138
1204
|
if (state === "blocked") {
|
|
1139
|
-
|
|
1205
|
+
swapLabel(store, project.name, issue, inProgress, project.stateLabels.blocked);
|
|
1140
1206
|
await safeEscalate(d, {
|
|
1141
1207
|
tier: 1,
|
|
1142
1208
|
project: project.name,
|
|
@@ -1159,8 +1225,12 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1159
1225
|
});
|
|
1160
1226
|
|
|
1161
1227
|
if (continueTurns) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1228
|
+
// Requeue as one ordered pair: the in-progress removal before the
|
|
1229
|
+
// queue add, exactly the order the projector will apply them in (#201).
|
|
1230
|
+
store.enqueueLabelOps(project.name, [
|
|
1231
|
+
{ issue, op: "remove", label: inProgress },
|
|
1232
|
+
{ issue, op: "add", label: project.queueLabel },
|
|
1233
|
+
]);
|
|
1164
1234
|
log(
|
|
1165
1235
|
`#${issue} turns-cap on run ${attempt}, continuation ` +
|
|
1166
1236
|
`${continuation}/${caps.maxContinuationsPerIssue} — salvaged and re-queued`,
|
|
@@ -1184,7 +1254,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1184
1254
|
].join("\n"),
|
|
1185
1255
|
});
|
|
1186
1256
|
} else {
|
|
1187
|
-
|
|
1257
|
+
swapLabel(store, project.name, issue, inProgress, project.stateLabels.failed);
|
|
1188
1258
|
await safeEscalate(d, {
|
|
1189
1259
|
tier: 1,
|
|
1190
1260
|
project: project.name,
|
|
@@ -1282,11 +1352,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1282
1352
|
if (claimed) {
|
|
1283
1353
|
// Leaving the issue stuck as in-progress would hide it from both the
|
|
1284
1354
|
// queue and the human, so relabel even on the error path.
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
log(`#${issue} could not be relabelled: ${errText(relabelErr)}`);
|
|
1289
|
-
}
|
|
1355
|
+
// The failure-path relabel cannot throw: it is a local outbox write, and
|
|
1356
|
+
// the projector retries until the tracker takes it (#201).
|
|
1357
|
+
swapLabel(store, project.name, issue, inProgress, project.stateLabels.failed);
|
|
1290
1358
|
}
|
|
1291
1359
|
|
|
1292
1360
|
const salvaged = settlement?.lines ?? [];
|
|
@@ -1360,46 +1428,35 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
1360
1428
|
}
|
|
1361
1429
|
|
|
1362
1430
|
/**
|
|
1363
|
-
*
|
|
1431
|
+
* Records the in-progress label's release as a projection op (#201).
|
|
1364
1432
|
*
|
|
1365
1433
|
* Settlement used to write only half of what it knew. On 2026-08-09 that cost
|
|
1366
1434
|
* the reference fleet two issues in one night: veltro#331 settled to `failed`
|
|
1367
1435
|
* at 23:53Z once the orchestrator closed veltro#332 unmerged, and veltro#344
|
|
1368
1436
|
* settled to `merged` at 05:54Z once chad#452 squash-merged. Both rows left the
|
|
1369
1437
|
* active set correctly; an authoritative `gh issue view` on each afterwards
|
|
1370
|
-
* still showed `agent:in-progress
|
|
1371
|
-
*
|
|
1372
|
-
* hand-editing one, and `unblock` refused to clear that particular label — so
|
|
1373
|
-
* both issues were permanently unclaimable with no supported way back (#18).
|
|
1438
|
+
* still showed `agent:in-progress` — permanently unclaimable with no supported
|
|
1439
|
+
* way back (#18).
|
|
1374
1440
|
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
* will find it.
|
|
1441
|
+
* The outbox makes the row transition and the label one fact again: the
|
|
1442
|
+
* removal is enqueued in the same breath as the row is terminalised, the
|
|
1443
|
+
* projector applies it with unbounded retry, and while it is pending the
|
|
1444
|
+
* eligibility overlay treats the label as already gone. A tracker that refuses
|
|
1445
|
+
* the write (403, rate limit) can no longer strand the row — that is `#184`
|
|
1446
|
+
* and `#198` closed. No `pushed-*` row is ever written terminal with its
|
|
1447
|
+
* label release owed but unrecorded, because enqueueing is a local store write
|
|
1448
|
+
* that cannot fail on the tracker.
|
|
1384
1449
|
*
|
|
1385
|
-
*
|
|
1386
|
-
* adapter treats an absent label as a no-op, so false means the tracker could
|
|
1387
|
-
* not be reached or refused — a condition that passes.
|
|
1450
|
+
* Synchronous. The op is durable the moment this returns.
|
|
1388
1451
|
*/
|
|
1389
|
-
export
|
|
1390
|
-
d: Pick<Deps, "project" | "
|
|
1452
|
+
export function releaseInProgress(
|
|
1453
|
+
d: Pick<Deps, "project" | "store">,
|
|
1391
1454
|
issue: number,
|
|
1392
1455
|
why: string,
|
|
1393
|
-
):
|
|
1456
|
+
): void {
|
|
1394
1457
|
const label = d.project.stateLabels.inProgress;
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
log(`#${issue} released ${label}: ${why}`);
|
|
1398
|
-
return true;
|
|
1399
|
-
} catch (err) {
|
|
1400
|
-
log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
|
|
1401
|
-
return false;
|
|
1402
|
-
}
|
|
1458
|
+
d.store.enqueueLabelOps(d.project.name, [{ issue, op: "remove", label }]);
|
|
1459
|
+
log(`#${issue} released ${label} (queued): ${why}`);
|
|
1403
1460
|
}
|
|
1404
1461
|
|
|
1405
1462
|
/**
|
|
@@ -1473,15 +1530,15 @@ export async function settlePushedGreen(
|
|
|
1473
1530
|
|
|
1474
1531
|
const settlement = settlementFor(pr, run.prUrl);
|
|
1475
1532
|
if (settlement !== undefined) {
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1533
|
+
// The label removal and the terminal row are one fact again (#201): the
|
|
1534
|
+
// release is enqueued — a durable local write that cannot fail on the
|
|
1535
|
+
// tracker — in the same breath as the row is terminalised, so there is
|
|
1536
|
+
// no window in which a row beyond every later tick still owes its label.
|
|
1537
|
+
// The projector applies it with retry; while pending, the eligibility
|
|
1538
|
+
// overlay treats the label as already gone, so #18's
|
|
1539
|
+
// permanent-`agent:in-progress` cannot re-form even when GitHub refuses
|
|
1540
|
+
// the write.
|
|
1541
|
+
releaseInProgress(d, run.issue, settlement.reason);
|
|
1485
1542
|
const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
|
|
1486
1543
|
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
1487
1544
|
store.updateRun(run.id, patch);
|
|
@@ -1502,11 +1559,11 @@ export async function settlePushedGreen(
|
|
|
1502
1559
|
store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
|
|
1503
1560
|
log(`#${run.issue} checks settled: ${verification.reason}`);
|
|
1504
1561
|
} else if (verification.status === "failed") {
|
|
1505
|
-
// Equally terminal, so the
|
|
1506
|
-
//
|
|
1507
|
-
// releases nothing — that row is still awaiting a merge,
|
|
1508
|
-
// is exactly the work the label must keep guarding.
|
|
1509
|
-
|
|
1562
|
+
// Equally terminal, so the release is enqueued before the row writes,
|
|
1563
|
+
// for the same reason as the settlement branch above (see there). The
|
|
1564
|
+
// green branch releases nothing — that row is still awaiting a merge,
|
|
1565
|
+
// and its live PR is exactly the work the label must keep guarding.
|
|
1566
|
+
releaseInProgress(d, run.issue, verification.reason);
|
|
1510
1567
|
store.updateRun(run.id, { state: "failed", lastError: verification.reason });
|
|
1511
1568
|
log(`#${run.issue} checks failed: ${verification.reason}`);
|
|
1512
1569
|
} else {
|
|
@@ -1757,6 +1814,13 @@ export async function admitCandidates(
|
|
|
1757
1814
|
if (list === undefined) activeByIssue.set(run.issue, [run]);
|
|
1758
1815
|
else list.push(run);
|
|
1759
1816
|
}
|
|
1817
|
+
// Live worker count per repo, seeded from live runs and incremented as this
|
|
1818
|
+
// same pass admits — so two same-repo candidates can never both clear the
|
|
1819
|
+
// per-repo cap in one tick (#186).
|
|
1820
|
+
const liveByRepo = new Map<string, number>();
|
|
1821
|
+
for (const run of store.liveRuns(project.name)) {
|
|
1822
|
+
liveByRepo.set(run.repo, (liveByRepo.get(run.repo) ?? 0) + 1);
|
|
1823
|
+
}
|
|
1760
1824
|
const holds: AdmissionHold[] = [];
|
|
1761
1825
|
const hold = (issue: number, reason: AdmissionHoldReason): void => {
|
|
1762
1826
|
holds.push({ issue, reason });
|
|
@@ -1781,10 +1845,12 @@ export async function admitCandidates(
|
|
|
1781
1845
|
return { admitted: [], holds };
|
|
1782
1846
|
}
|
|
1783
1847
|
|
|
1784
|
-
// parent -> blocking issue. Seeded from active runs (including
|
|
1785
|
-
// then extended by candidates admitted earlier in this same
|
|
1786
|
-
// siblings never both clear the gate in one tick.
|
|
1787
|
-
|
|
1848
|
+
// parent -> repo name -> blocking issue. Seeded from active runs (including
|
|
1849
|
+
// pushed-green), then extended by candidates admitted earlier in this same
|
|
1850
|
+
// pass so two siblings of one epic never both clear the gate in one tick.
|
|
1851
|
+
// A busy issue whose run row cannot be resolved occupies the sentinel repo
|
|
1852
|
+
// "" — treated as matching every repo, failing toward holding (#197).
|
|
1853
|
+
const occupiedParents = new Map<number, Map<string, number>>();
|
|
1788
1854
|
const parentCache = new Map<number, number | undefined>();
|
|
1789
1855
|
|
|
1790
1856
|
const resolveParent = async (issue: number): Promise<number | undefined> => {
|
|
@@ -1799,8 +1865,18 @@ export async function admitCandidates(
|
|
|
1799
1865
|
for (const issue of busyIssues) {
|
|
1800
1866
|
try {
|
|
1801
1867
|
const parent = await resolveParent(issue);
|
|
1802
|
-
if (parent
|
|
1803
|
-
|
|
1868
|
+
if (parent === undefined) continue;
|
|
1869
|
+
// The runs table records which repo each attempt worked in, and sibling
|
|
1870
|
+
// holds are now per-repo, so a busy child only occupies its epic under
|
|
1871
|
+
// that repo's name (same spelling as `createRun` writes from
|
|
1872
|
+
// `r.repo.name`). A busy issue with no resolvable run row occupies the
|
|
1873
|
+
// sentinel "" instead — matching every repo, failing toward holding.
|
|
1874
|
+
const repo = store.latestRun(project.name, issue)?.repo ?? "";
|
|
1875
|
+
const siblings = occupiedParents.get(parent);
|
|
1876
|
+
if (siblings === undefined) {
|
|
1877
|
+
occupiedParents.set(parent, new Map([[repo, issue]]));
|
|
1878
|
+
} else if (!siblings.has(repo) && !siblings.has("")) {
|
|
1879
|
+
siblings.set(repo, issue);
|
|
1804
1880
|
}
|
|
1805
1881
|
} catch (err) {
|
|
1806
1882
|
log(`#${issue} parent lookup failed while seeding epic occupancy (${errText(err)})`);
|
|
@@ -1828,6 +1904,16 @@ export async function admitCandidates(
|
|
|
1828
1904
|
}
|
|
1829
1905
|
}
|
|
1830
1906
|
|
|
1907
|
+
// Per-repo concurrency: the mirror, branch-protection staleness and shared
|
|
1908
|
+
// CI egress are all per-repo collision domains, so extra slots should land
|
|
1909
|
+
// on other repos rather than stacking workers into the same one (#186).
|
|
1910
|
+
const liveInRepo = liveByRepo.get(r.repo.name) ?? 0;
|
|
1911
|
+
if (liveInRepo >= caps.maxConcurrentWorkersPerRepo) {
|
|
1912
|
+
hold(issue, "repo-active");
|
|
1913
|
+
log(`#${issue} skipped: ${liveInRepo} live worker(s) already in ${r.repo.name} (cap ${caps.maxConcurrentWorkersPerRepo})`);
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1831
1917
|
const priorRuns = store.attemptsFor(project.name, issue);
|
|
1832
1918
|
const failures = store.failuresFor(project.name, issue);
|
|
1833
1919
|
if (failures >= caps.maxAttemptsPerIssue) {
|
|
@@ -1890,10 +1976,13 @@ export async function admitCandidates(
|
|
|
1890
1976
|
continue;
|
|
1891
1977
|
}
|
|
1892
1978
|
|
|
1893
|
-
// Soft concurrency per epic: at most one in-flight child of
|
|
1894
|
-
//
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
1979
|
+
// Soft concurrency per epic, per repository: at most one in-flight child of
|
|
1980
|
+
// a given parent in each repo. Children of one epic in *different* repos
|
|
1981
|
+
// parallelise freely — `repo-active` / `maxConcurrentWorkersPerRepo` owns
|
|
1982
|
+
// the same-repo collision domain (#197). The "" sentinel matches every
|
|
1983
|
+
// repo. No parent means today's concurrent admission. Cheap local filters
|
|
1984
|
+
// already ran; this sits before the open-PR API call so a held sibling
|
|
1985
|
+
// frees the slot for unrelated work without spending a closers query.
|
|
1897
1986
|
let parent: number | undefined;
|
|
1898
1987
|
try {
|
|
1899
1988
|
parent = await resolveParent(issue);
|
|
@@ -1903,10 +1992,11 @@ export async function admitCandidates(
|
|
|
1903
1992
|
continue;
|
|
1904
1993
|
}
|
|
1905
1994
|
if (parent !== undefined) {
|
|
1906
|
-
const
|
|
1995
|
+
const occupied = occupiedParents.get(parent);
|
|
1996
|
+
const blocker = occupied?.get(r.repo.name) ?? occupied?.get("");
|
|
1907
1997
|
if (blocker !== undefined) {
|
|
1908
1998
|
hold(issue, "sibling-active");
|
|
1909
|
-
log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent}`);
|
|
1999
|
+
log(`#${issue} skipped: sibling #${blocker} in flight under epic #${parent} in ${r.repo.name}`);
|
|
1910
2000
|
continue;
|
|
1911
2001
|
}
|
|
1912
2002
|
}
|
|
@@ -1976,7 +2066,18 @@ export async function admitCandidates(
|
|
|
1976
2066
|
}
|
|
1977
2067
|
|
|
1978
2068
|
admitted.push({ r, attempt: priorRuns + 1 });
|
|
1979
|
-
|
|
2069
|
+
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
2070
|
+
if (parent !== undefined) {
|
|
2071
|
+
// Extend the epic's occupancy under this repo (slot empty by construction
|
|
2072
|
+
// here — the gate above would have held the candidate otherwise) so a
|
|
2073
|
+
// same-repo sibling later in this pass does not clear the gate (#197).
|
|
2074
|
+
let siblings = occupiedParents.get(parent);
|
|
2075
|
+
if (siblings === undefined) {
|
|
2076
|
+
siblings = new Map();
|
|
2077
|
+
occupiedParents.set(parent, siblings);
|
|
2078
|
+
}
|
|
2079
|
+
if (!siblings.has(r.repo.name)) siblings.set(r.repo.name, issue);
|
|
2080
|
+
}
|
|
1980
2081
|
}
|
|
1981
2082
|
|
|
1982
2083
|
return { admitted, holds };
|
|
@@ -2072,6 +2173,18 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2072
2173
|
log(`label reconcile failed: ${errText(err)}`);
|
|
2073
2174
|
}
|
|
2074
2175
|
|
|
2176
|
+
// Drain the label projection outbox (#201). The maintenance phases above may
|
|
2177
|
+
// have enqueued ops (settlement releases, recovery requeues, reconciles);
|
|
2178
|
+
// each due op is applied now — or deferred with backoff for the next tick —
|
|
2179
|
+
// before the queue is read, so dispatch sees labels converging on what the
|
|
2180
|
+
// store decided. A refusing tracker defers ops instead of taking the tick
|
|
2181
|
+
// down.
|
|
2182
|
+
try {
|
|
2183
|
+
await projectLabels(d.store, d.tracker, d.project);
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
log(`label projection failed: ${errText(err)}`);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2075
2188
|
// Ledger maintenance, above the pause gate for the same reason the stall watch
|
|
2076
2189
|
// is: a paused fleet still owes its operator the questions it asked, and a
|
|
2077
2190
|
// condition that came true while dispatch was parked is exactly the thing the
|
|
@@ -2083,7 +2196,13 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2083
2196
|
for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
|
|
2084
2197
|
log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
|
|
2085
2198
|
}
|
|
2086
|
-
void evaluateDecisionConditions(
|
|
2199
|
+
void evaluateDecisionConditions(
|
|
2200
|
+
d.store,
|
|
2201
|
+
d.project.name,
|
|
2202
|
+
d.tracker,
|
|
2203
|
+
{ npm: probeNpmVersion, rateLimit: probeRateLimitReset },
|
|
2204
|
+
Date.now,
|
|
2205
|
+
)
|
|
2087
2206
|
.then((met) => {
|
|
2088
2207
|
for (const decision of met) {
|
|
2089
2208
|
log(`decision ${decision.id} condition met (${decision.condition ?? "?"}) — surfacing on the next tick`);
|
|
@@ -2122,7 +2241,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2122
2241
|
`ERROR: the installed conductor changed under this daemon — ${integrity.diff.length} file(s) differ ` +
|
|
2123
2242
|
`(${shown.join(", ")}${integrity.diff.length > shown.length ? ", …" : ""}) — pausing`,
|
|
2124
2243
|
);
|
|
2125
|
-
setPaused(true);
|
|
2244
|
+
setPaused(true, { source: "integrity", reason: "installed package changed under the daemon" });
|
|
2126
2245
|
if (integrity.page) {
|
|
2127
2246
|
const delivered = await safeEscalate(d, {
|
|
2128
2247
|
tier: 2,
|
|
@@ -2158,7 +2277,17 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2158
2277
|
new Set(ready.map((issue) => issue.number)),
|
|
2159
2278
|
d.cleanup ?? { next: 0 },
|
|
2160
2279
|
);
|
|
2161
|
-
|
|
2280
|
+
// Label-projection overlay (#201): an issue whose outbox ops have not
|
|
2281
|
+
// reached GitHub yet is judged on what its labels *will* be. A pending
|
|
2282
|
+
// state-label removal stops a stale GitHub label from blocking redispatch,
|
|
2283
|
+
// and a pending queue-label removal drops the issue out of eligibility even
|
|
2284
|
+
// though the label is still physically present. isEligible stays pure; the
|
|
2285
|
+
// overlay happens here.
|
|
2286
|
+
const effective = ready.map((issue) => {
|
|
2287
|
+
const pending = store.pendingLabelOpsFor(project.name, issue.number);
|
|
2288
|
+
return pending.length === 0 ? issue : { ...issue, labels: effectiveLabels(issue.labels, pending) };
|
|
2289
|
+
});
|
|
2290
|
+
const { routed, unroutable } = route(effective, project);
|
|
2162
2291
|
const routingHolds: AdmissionHold[] = unroutable.map((u) => ({
|
|
2163
2292
|
issue: u.issue.number,
|
|
2164
2293
|
reason: `unroutable:${u.reason}`,
|
|
@@ -2197,7 +2326,7 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2197
2326
|
// operator opted out — turns and wall-clock still brake every run (#46).
|
|
2198
2327
|
const spent = store.spendSince(project.name, since);
|
|
2199
2328
|
if (caps.dailySpendUsd !== null && spent >= caps.dailySpendUsd) {
|
|
2200
|
-
setPaused(true);
|
|
2329
|
+
setPaused(true, { source: "spend-cap", reason: `daily spend reached $${caps.dailySpendUsd}` });
|
|
2201
2330
|
await safeEscalate(d, {
|
|
2202
2331
|
tier: 2,
|
|
2203
2332
|
project: project.name,
|
|
@@ -2242,6 +2371,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2242
2371
|
(a) => handleIssue(d, a.r, a.attempt),
|
|
2243
2372
|
workers,
|
|
2244
2373
|
);
|
|
2374
|
+
|
|
2375
|
+
// Post-admission flush: freshly claimed runs enqueued their in-progress
|
|
2376
|
+
// label inside `handleIssue`; applying it now means the guard label lands on
|
|
2377
|
+
// GitHub within the same tick on the healthy path, not five minutes later
|
|
2378
|
+
// (#201).
|
|
2379
|
+
try {
|
|
2380
|
+
await projectLabels(store, d.tracker, project);
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
log(`label projection failed: ${errText(err)}`);
|
|
2383
|
+
}
|
|
2245
2384
|
}
|
|
2246
2385
|
|
|
2247
2386
|
// --------------------------------------------------------------- read-only views
|
|
@@ -2403,6 +2542,28 @@ export interface StatusSnapshot {
|
|
|
2403
2542
|
* than print a percentage nobody measured (#110).
|
|
2404
2543
|
*/
|
|
2405
2544
|
planUsage?: PlanUsageStatus;
|
|
2545
|
+
/**
|
|
2546
|
+
* The GitHub API rate-limit budget, when the caller read one. Optional for
|
|
2547
|
+
* the same reason as `planUsage` — the read is I/O and the snapshot is
|
|
2548
|
+
* synchronous — and a broken `gh` must cost one status row, not the report
|
|
2549
|
+
* (#188).
|
|
2550
|
+
*/
|
|
2551
|
+
github?: RateLimitStatus;
|
|
2552
|
+
/**
|
|
2553
|
+
* Observed GitHub rate-limit refusals within the last five minutes, and the
|
|
2554
|
+
* daemon's tracked per-source `gh` call counts for the UTC day. Unlike
|
|
2555
|
+
* `github`, which a caller polls, these are written by the tracker's hooks —
|
|
2556
|
+
* the polled budget sat beside what actually happened (#198).
|
|
2557
|
+
*/
|
|
2558
|
+
ghRefusals?: { count: number; latestAt?: number };
|
|
2559
|
+
ghCallsToday?: readonly { source: string; calls: number }[];
|
|
2560
|
+
/**
|
|
2561
|
+
* Label-projection ops still owed to the tracker (#201). Present only while
|
|
2562
|
+
* one is pending: GitHub has not yet converged on what the store decided —
|
|
2563
|
+
* a refused or deferred label write is exactly the state an operator should
|
|
2564
|
+
* see rather than a silent gap.
|
|
2565
|
+
*/
|
|
2566
|
+
labelOps?: { pending: number; oldestAgeMs: number };
|
|
2406
2567
|
}
|
|
2407
2568
|
|
|
2408
2569
|
/** Builds a status reading from an already-open store. Long-lived operator
|
|
@@ -2416,6 +2577,8 @@ export function statusSnapshotFromStore(
|
|
|
2416
2577
|
): StatusSnapshot {
|
|
2417
2578
|
const since = startOfToday();
|
|
2418
2579
|
const dispatch = store.latestDispatch(p.name);
|
|
2580
|
+
const labelOpsPending = store.countPendingLabelOps(p.name);
|
|
2581
|
+
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
2419
2582
|
return {
|
|
2420
2583
|
project: p.name,
|
|
2421
2584
|
configPath: configPath(),
|
|
@@ -2432,6 +2595,13 @@ export function statusSnapshotFromStore(
|
|
|
2432
2595
|
spendTodayUsd: store.spendSince(p.name, since),
|
|
2433
2596
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
2434
2597
|
...(planUsage === undefined ? {} : { planUsage }),
|
|
2598
|
+
// Written by the tracker's hooks rather than polled, so the renderer does
|
|
2599
|
+
// not re-read GitHub to know it is being refused (#198).
|
|
2600
|
+
ghRefusals: store.ghRefusalsSince?.(Date.now() - 5 * 60_000),
|
|
2601
|
+
ghCallsToday: store.ghCallsToday?.(utcDay()),
|
|
2602
|
+
...(labelOpsPending === 0 || oldestLabelOpAt === undefined
|
|
2603
|
+
? {}
|
|
2604
|
+
: { labelOps: { pending: labelOpsPending, oldestAgeMs: Date.now() - oldestLabelOpAt } }),
|
|
2435
2605
|
};
|
|
2436
2606
|
}
|
|
2437
2607
|
|
|
@@ -2629,7 +2799,7 @@ export async function previewQueue(project?: string): Promise<QueuePreview> {
|
|
|
2629
2799
|
*/
|
|
2630
2800
|
export function prepareConductor(): void {
|
|
2631
2801
|
openStore(dbPath()).close();
|
|
2632
|
-
setPaused(true);
|
|
2802
|
+
setPaused(true, { source: "setup" });
|
|
2633
2803
|
}
|
|
2634
2804
|
|
|
2635
2805
|
/** Bounded per tick: each row costs tracker calls to gather facts for. */
|
|
@@ -2764,11 +2934,11 @@ async function recoverRun(
|
|
|
2764
2934
|
const inProgress = project.stateLabels.inProgress;
|
|
2765
2935
|
|
|
2766
2936
|
if (recovery === "settle") {
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
2771
|
-
|
|
2937
|
+
// Enqueue the release with the terminal write (see `settlePushedGreen`):
|
|
2938
|
+
// the outbox keeps the label and the row one fact, so a tracker refusal
|
|
2939
|
+
// can no longer strand `agent:in-progress` with nothing left to retry it
|
|
2940
|
+
// (#18, #201).
|
|
2941
|
+
releaseInProgress(d, run.issue, `PR merged: ${evidence}`);
|
|
2772
2942
|
store.updateRun(run.id, { state: "merged", endedAt: Date.now(), recoveredAt: Date.now() });
|
|
2773
2943
|
log(`#${run.issue} settled from ${cls}: ${evidence}`);
|
|
2774
2944
|
return;
|
|
@@ -2794,13 +2964,15 @@ async function recoverRun(
|
|
|
2794
2964
|
// `merge-conflict`: the branch is retained and its PR is open, so #50's
|
|
2795
2965
|
// continuation guard admits it and the next tick briefs a rebase.
|
|
2796
2966
|
//
|
|
2797
|
-
// The
|
|
2798
|
-
//
|
|
2799
|
-
// `
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
//
|
|
2803
|
-
|
|
2967
|
+
// The outbox makes the retry contract one-sided: the swap is enqueued — a
|
|
2968
|
+
// durable local write that cannot fail on the tracker — before
|
|
2969
|
+
// `recoveredAt` is written, so the row can never again be taken out of
|
|
2970
|
+
// `runsNeedingClassification` with its label swap still owed. That was
|
|
2971
|
+
// the defect 0.4.4 claimed to have fixed and did not, for this one
|
|
2972
|
+
// recovery; the projector retries until the tracker takes the swap, and
|
|
2973
|
+
// while it is pending the eligibility overlay keeps the issue coherent
|
|
2974
|
+
// (#201).
|
|
2975
|
+
swapToQueue(d, run.issue, inProgress);
|
|
2804
2976
|
store.updateRun(run.id, {
|
|
2805
2977
|
state: "killed",
|
|
2806
2978
|
lastError:
|
|
@@ -2841,7 +3013,7 @@ async function recoverRun(
|
|
|
2841
3013
|
return;
|
|
2842
3014
|
}
|
|
2843
3015
|
const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
|
|
2844
|
-
|
|
3016
|
+
swapToQueue(d, run.issue, label);
|
|
2845
3017
|
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
2846
3018
|
log(`#${run.issue} requeued from ${cls}: ${evidence}`);
|
|
2847
3019
|
return;
|
|
@@ -2907,26 +3079,22 @@ async function recoverRun(
|
|
|
2907
3079
|
}
|
|
2908
3080
|
|
|
2909
3081
|
/**
|
|
2910
|
-
*
|
|
3082
|
+
* Enqueue a state-label → queue-label swap for projection (#201).
|
|
2911
3083
|
*
|
|
2912
|
-
*
|
|
2913
|
-
*
|
|
2914
|
-
*
|
|
2915
|
-
*
|
|
3084
|
+
* The swap is two ops in id order — remove first, then add — which is the
|
|
3085
|
+
* atomicity the projector guarantees: the issue never sits newly eligible
|
|
3086
|
+
* without a queue label on its way back, and the add never lands before the
|
|
3087
|
+
* remove when GitHub fails between them. Enqueueing is a durable local write
|
|
3088
|
+
* that cannot fail on the tracker, so the caller records its recovery
|
|
3089
|
+
* immediately and the projector retries the swap until the tracker takes it —
|
|
3090
|
+
* that closes the 0.4.4 hole where a refused label swap stranded the row
|
|
3091
|
+
* permanently under a log line promising a retry.
|
|
2916
3092
|
*/
|
|
2917
|
-
|
|
2918
|
-
d
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
)
|
|
2922
|
-
try {
|
|
2923
|
-
await d.tracker.removeLabel(issue, label);
|
|
2924
|
-
await d.tracker.addLabel(issue, d.project.queueLabel);
|
|
2925
|
-
return true;
|
|
2926
|
-
} catch (err) {
|
|
2927
|
-
log(`#${issue} could not be requeued (${errText(err)}) — retrying next tick`);
|
|
2928
|
-
return false;
|
|
2929
|
-
}
|
|
3093
|
+
function swapToQueue(d: Pick<Deps, "project" | "store">, issue: number, label: string): void {
|
|
3094
|
+
d.store.enqueueLabelOps(d.project.name, [
|
|
3095
|
+
{ issue, op: "remove", label },
|
|
3096
|
+
{ issue, op: "add", label: d.project.queueLabel },
|
|
3097
|
+
]);
|
|
2930
3098
|
}
|
|
2931
3099
|
|
|
2932
3100
|
/** Bounded listing per state label, so one reconcile cannot walk a whole repo. */
|
|
@@ -2956,13 +3124,11 @@ export async function reconcileStaleLabels(d: Deps): Promise<void> {
|
|
|
2956
3124
|
for (const issue of carrying) {
|
|
2957
3125
|
if (issue.state === "closed") {
|
|
2958
3126
|
// Never retain an `agent:*` label on a closed issue: the work is done by
|
|
2959
|
-
// some route, and the label only makes the board lie about it.
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
}
|
|
2964
|
-
log(`#${issue.number} could not drop ${label} (${errText(err)}) — retrying next tick`);
|
|
2965
|
-
}
|
|
3127
|
+
// some route, and the label only makes the board lie about it. Enqueue
|
|
3128
|
+
// rather than call — a refused write must not lose the decision; the
|
|
3129
|
+
// projector retries the removal until the tracker takes it (#201).
|
|
3130
|
+
store.enqueueLabelOps(project.name, [{ issue: issue.number, op: "remove", label }]);
|
|
3131
|
+
log(`#${issue.number} reconciled: closed issue no longer carries ${label} (queued)`);
|
|
2966
3132
|
continue;
|
|
2967
3133
|
}
|
|
2968
3134
|
|
|
@@ -2973,8 +3139,8 @@ export async function reconcileStaleLabels(d: Deps): Promise<void> {
|
|
|
2973
3139
|
const key = `${project.name}:superseded:${issue.number}`;
|
|
2974
3140
|
if (store.wasNotified(key)) continue;
|
|
2975
3141
|
const list = children.map((c) => `#${c.number}`).join(", ");
|
|
3142
|
+
store.enqueueLabelOps(project.name, [{ issue: issue.number, op: "remove", label }]);
|
|
2976
3143
|
try {
|
|
2977
|
-
await tracker.removeLabel(issue.number, label);
|
|
2978
3144
|
await tracker.comment(
|
|
2979
3145
|
issue.number,
|
|
2980
3146
|
`superseded: all sub-issues closed (${list}) — this label was stale; propose closing if the ` +
|
|
@@ -2983,7 +3149,9 @@ export async function reconcileStaleLabels(d: Deps): Promise<void> {
|
|
|
2983
3149
|
store.markNotified(key);
|
|
2984
3150
|
log(`#${issue.number} reconciled: superseded by ${list}`);
|
|
2985
3151
|
} catch (err) {
|
|
2986
|
-
|
|
3152
|
+
// The label removal is already queued and will land regardless; the
|
|
3153
|
+
// comment is the only half that can fail here (#201).
|
|
3154
|
+
log(`#${issue.number} could not comment the superseded note (${errText(err)}) — retrying next tick`);
|
|
2987
3155
|
}
|
|
2988
3156
|
}
|
|
2989
3157
|
}
|
|
@@ -3059,7 +3227,19 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3059
3227
|
const project = findProject(cfg, o.project);
|
|
3060
3228
|
const caps = resolveCaps(project, cfg.defaults);
|
|
3061
3229
|
const store = openStore(dbPath());
|
|
3062
|
-
|
|
3230
|
+
// The tracker's single `gh` funnel is bound to the store so the operator sees
|
|
3231
|
+
// observed truth (#198): every spawn is counted per UTC day, and every
|
|
3232
|
+
// rate-limit refusal is recorded for `status`'s 5m window. Board's ad-hoc
|
|
3233
|
+
// trackers and the polled `fetchRateLimit` probe are deliberately not bound —
|
|
3234
|
+
// this row is the daemon's own traffic.
|
|
3235
|
+
const tracker = makeTracker(project, undefined, {
|
|
3236
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
3237
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
3238
|
+
// A conditional 304 revalidation is a spawn but not a billed read (#203);
|
|
3239
|
+
// counted separately so `status`'s call row keeps telling the truth once
|
|
3240
|
+
// most spawns are free revalidations.
|
|
3241
|
+
onNotModified: () => store.bumpGhCalls?.(utcDay(), "daemon-304"),
|
|
3242
|
+
});
|
|
3063
3243
|
|
|
3064
3244
|
// #126's transport, stated at startup rather than guessed at first use. The
|
|
3065
3245
|
// banner names what this host can actually enforce — whether the kernel will
|