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/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 { dbPath, openStore } from "./store.ts";
40
- import { makeTracker } from "./tracker/github.ts";
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
- export function setPaused(v: boolean): void {
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
- writeFileSync(f, `${new Date().toISOString()}\n`);
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
- * Add the new label before dropping the old one. The reverse order leaves a
485
- * window where the issue carries no state label at all, which is exactly the
486
- * shape `isEligible` treats as fresh work.
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
- async function swapLabel(tracker: Tracker, issue: number, from: string, to: string): Promise<void> {
489
- await tracker.addLabel(issue, to);
490
- await tracker.removeLabel(issue, from);
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({ report: claim.report, issueText: claim.issueText, diff }),
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 tracker FIRST, before any local work. The label not the
908
- // store — is the crash-safe guard against double dispatch: if this process
909
- // dies mid-run, the next daemon sees the label, `isEligible` filters the
910
- // issue out, and a human decides what to do with the orphan.
911
- await tracker.addLabel(issue, inProgress);
912
- claimed = true;
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 ? [] : formatSettlementFlags(audit.flags, { truncated: audit.truncated });
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
- await swapLabel(tracker, issue, inProgress, project.stateLabels.blocked);
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
- await tracker.removeLabel(issue, inProgress);
1163
- await tracker.addLabel(issue, project.queueLabel);
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
- await swapLabel(tracker, issue, inProgress, project.stateLabels.failed);
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
- try {
1286
- await swapLabel(tracker, issue, inProgress, project.stateLabels.failed);
1287
- } catch (relabelErr) {
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
- * Drops the in-progress label from an issue whose run is provably over.
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`. `routing.isEligible` rejects any issue
1371
- * carrying a state label, the composed brief forbids the orchestrator from
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
- * Never throws, and reports whether the label is provably gone, because the
1376
- * caller has to decide what to write to the store on the strength of it. A
1377
- * sweep must not lose the rest of its rows to one unreachable tracker, and it
1378
- * must not terminalise a row whose label it failed to drop: the settlement
1379
- * sweep only ever revisits `pushed-*` rows, so a row written terminal is a row
1380
- * nothing asks about again, and swallowing the failure under it would recreate
1381
- * the exact permanent-`agent:in-progress` state of #18 in the one case that
1382
- * still reaches it. Answering false instead leaves the row where the next tick
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
- * Removing a label the issue does not carry is success, not failure: the GitHub
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 async function releaseInProgress(
1390
- d: Pick<Deps, "project" | "tracker">,
1452
+ export function releaseInProgress(
1453
+ d: Pick<Deps, "project" | "store">,
1391
1454
  issue: number,
1392
1455
  why: string,
1393
- ): Promise<boolean> {
1456
+ ): void {
1394
1457
  const label = d.project.stateLabels.inProgress;
1395
- try {
1396
- await d.tracker.removeLabel(issue, label);
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
- // Label first, row second, and the order is the whole safety argument.
1477
- // The sweep only ever revisits `pushed-*` rows, so writing the terminal
1478
- // state first would put this row beyond every later tick and a tracker
1479
- // that failed on the label in that instant would strand
1480
- // `agent:in-progress` permanently, which is #18 again in the one window
1481
- // still able to reach it. Leaving the row `pushed-*` costs a stale active
1482
- // row until the tracker answers, and the busy set keeps the issue
1483
- // occupied meanwhile, so nothing can be dispatched onto it in between.
1484
- if (!(await releaseInProgress(d, run.issue, settlement.reason))) continue;
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 same label-first order for the same reason:
1506
- // this row is about to leave the sweep's reach. The green branch above
1507
- // releases nothing — that row is still awaiting a merge, and its live PR
1508
- // is exactly the work the label must keep guarding.
1509
- if (!(await releaseInProgress(d, run.issue, verification.reason))) continue;
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 pushed-green),
1785
- // then extended by candidates admitted earlier in this same pass so two
1786
- // siblings never both clear the gate in one tick.
1787
- const occupiedParents = new Map<number, number>();
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 !== undefined && !occupiedParents.has(parent)) {
1803
- occupiedParents.set(parent, issue);
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 a given parent.
1894
- // No parent means today's concurrent admission. Cheap local filters already
1895
- // ran; this sits before the open-PR API call so a held sibling frees the
1896
- // slot for unrelated work without spending a closers query.
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 blocker = occupiedParents.get(parent);
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
- if (parent !== undefined) occupiedParents.set(parent, issue);
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(d.store, d.project.name, d.tracker, probeNpmVersion, Date.now)
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
- const { routed, unroutable } = route(ready, project);
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
- // Label before row, copied from `settlePushedGreen` where the order is the
2768
- // whole safety argument: writing the terminal state first would put this row
2769
- // beyond every later tick, and a tracker that then failed on the label would
2770
- // strand `agent:in-progress` with nothing left to retry it (#18).
2771
- if (!(await releaseInProgress(d, run.issue, `PR merged: ${evidence}`))) return;
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 tracker write goes FIRST, and that ordering is the whole retry
2798
- // contract. Writing `recoveredAt` before the swap took the row out of
2799
- // `runsNeedingClassification` which selects on `recoveredAt IS NULL` so a
2800
- // refused label swap stranded the row permanently under a log line promising
2801
- // a retry. That was the defect 0.4.4 claimed to have fixed and did not, for
2802
- // this one recovery.
2803
- if (!(await swapToQueue(d, run.issue, inProgress))) return;
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
- if (!(await swapToQueue(d, run.issue, label))) return;
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
- * Swap a state label for the queue label, in that order.
3082
+ * Enqueue a state-label queue-label swap for projection (#201).
2911
3083
  *
2912
- * Both writes go through the same Tracker port the dispatcher claimed with, so
2913
- * orphan detection stays trustworthy. Returns false when either half failed, so
2914
- * the caller leaves the row for the next tick rather than recording a recovery
2915
- * that did not happen.
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
- async function swapToQueue(
2918
- d: Pick<Deps, "project" | "tracker">,
2919
- issue: number,
2920
- label: string,
2921
- ): Promise<boolean> {
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
- try {
2961
- await tracker.removeLabel(issue.number, label);
2962
- log(`#${issue.number} reconciled: closed issue no longer carries ${label}`);
2963
- } catch (err) {
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
- log(`#${issue.number} could not be reconciled (${errText(err)}) retrying next tick`);
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
- const tracker = makeTracker(project);
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