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/board.ts CHANGED
@@ -20,13 +20,21 @@ import type {
20
20
  AdmissionHoldReason,
21
21
  PrVerification,
22
22
  ProjectConfig,
23
+ ReadyIssue,
23
24
  RunRecord,
24
25
  RunState,
25
26
  Store,
27
+ Tracker,
26
28
  } from "./types.ts";
27
29
 
28
30
  const REFRESH_MS = 1_000;
29
31
  const HEALTH_REFRESH_MS = 10_000;
32
+ /** Base cadence for the tracker label read, and the ceiling the idle backoff
33
+ * in {@link nextLabelDelayMs} doubles toward. Separate from the health gate
34
+ * above because only this one leaves the machine: daemon state and the plan
35
+ * allowance are local reads and stay fresh at the base cadence regardless. */
36
+ const LABEL_REFRESH_MS = 10_000;
37
+ const LABEL_REFRESH_MAX_MS = 60_000;
30
38
  /** Cadence for re-verifying pushed rows against GitHub — deliberately slower
31
39
  * than the board's health/label probe: it is one `gh` call per pushed row, and
32
40
  * pull-request state does not move at 1 Hz. #173. */
@@ -176,6 +184,10 @@ export interface BoardSnapshot {
176
184
  * the PR looks like now. Rendered as a `now:` suffix on the card. */
177
185
  pr: ReadonlyMap<string, { status: PrVerification["status"]; reason: string }>;
178
186
  runs: RunRecord[];
187
+ /** What this board itself has spent on the tracker in the last minute, and
188
+ * how much of that was free 304s (#210). Absent in one-shot contexts like
189
+ * `board --json`, which have no rate to report. */
190
+ trackerCost?: { calls: number; free: number };
179
191
  now: number;
180
192
  }
181
193
 
@@ -399,6 +411,83 @@ export function boardLanes(snapshot: BoardSnapshot): Map<BoardLane, number[]> {
399
411
  return lanes;
400
412
  }
401
413
 
414
+ /**
415
+ * The four lifecycle label sets of one open-issue snapshot.
416
+ *
417
+ * Label compare is case-insensitive: GitHub label names are unique
418
+ * case-insensitively, and the server-side filter this replaces matched them
419
+ * that way. An issue carrying more than one of the four lands in each matching
420
+ * set, exactly as the four separate label queries did — `laneOf` already
421
+ * resolves that precedence.
422
+ */
423
+ export function boardLabelSets(
424
+ issues: readonly ReadyIssue[],
425
+ project: ProjectConfig,
426
+ ): { queued: Set<number>; inProgress: Set<number>; blocked: Set<number>; failed: Set<number> } {
427
+ const queued = new Set<number>();
428
+ const inProgress = new Set<number>();
429
+ const blocked = new Set<number>();
430
+ const failed = new Set<number>();
431
+ const names: Record<"queued" | "inProgress" | "blocked" | "failed", string> = {
432
+ queued: project.queueLabel.toLowerCase(),
433
+ inProgress: project.stateLabels.inProgress.toLowerCase(),
434
+ blocked: project.stateLabels.blocked.toLowerCase(),
435
+ failed: project.stateLabels.failed.toLowerCase(),
436
+ };
437
+ for (const issue of issues) {
438
+ for (const label of issue.labels) {
439
+ const lower = label.toLowerCase();
440
+ if (lower === names.queued) queued.add(issue.number);
441
+ else if (lower === names.inProgress) inProgress.add(issue.number);
442
+ else if (lower === names.blocked) blocked.add(issue.number);
443
+ else if (lower === names.failed) failed.add(issue.number);
444
+ }
445
+ }
446
+ return { queued, inProgress, blocked, failed };
447
+ }
448
+
449
+ function sameIssueSet(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
450
+ if (a.size !== b.size) return false;
451
+ for (const issue of a) if (!b.has(issue)) return false;
452
+ return true;
453
+ }
454
+
455
+ /**
456
+ * Whether two label reads say the same thing.
457
+ *
458
+ * Read outcome counts as well as membership, but only whether a read *failed*
459
+ * — never the message. GitHub's failures carry a fresh request id every time
460
+ * (`... request ID E05A:12929D:...`), so comparing text would score two
461
+ * identical failures as a change and pin the fast cadence on a tracker that is
462
+ * broken, which is exactly backwards.
463
+ */
464
+ export function boardLabelsEqual(a: BoardLabels, b: BoardLabels): boolean {
465
+ return (
466
+ sameIssueSet(a.queued, b.queued) &&
467
+ sameIssueSet(a.inProgress, b.inProgress) &&
468
+ sameIssueSet(a.blocked, b.blocked) &&
469
+ sameIssueSet(a.failed, b.failed) &&
470
+ sameIssueSet(a.open, b.open) &&
471
+ (a.error === undefined) === (b.error === undefined)
472
+ );
473
+ }
474
+
475
+ /**
476
+ * How long to wait before the next tracker label read.
477
+ *
478
+ * #203 made that read a single conditional request that answers 304 while
479
+ * nothing changes, so an idle board no longer spends rate-limit budget — but a
480
+ * request nobody needs is still a request, and a board left open in a pane all
481
+ * day makes thousands of them. Each pass that finds nothing changed doubles the
482
+ * wait up to {@link LABEL_REFRESH_MAX_MS}; any change, and any operator
483
+ * keypress, drops it straight back to {@link LABEL_REFRESH_MS}. So the board is
484
+ * never more than a minute behind, and is back to base cadence the moment
485
+ * either the work or the operator moves.
486
+ */
487
+ export function nextLabelDelayMs(current: number, unchanged: boolean): number {
488
+ return unchanged ? Math.min(current * 2, LABEL_REFRESH_MAX_MS) : LABEL_REFRESH_MS;
489
+ }
490
+
402
491
  function cardIssue(card: BoardCard): number {
403
492
  return card.kind === "issue" ? card.issue : card.run.issue;
404
493
  }
@@ -562,15 +651,41 @@ function healthLine(snapshot: BoardSnapshot): string {
562
651
  ].join(" · ");
563
652
  }
564
653
 
565
- /** How current the label sets behind the lanes are. A lane rule is only as
566
- * authoritative as its last successful read, so a stale one says so instead of
567
- * letting an empty QUEUE read as an empty queue. */
654
+ /**
655
+ * Sliding one-minute count of the board's own tracker requests, pruning what
656
+ * has aged out of the window.
657
+ *
658
+ * #210 asks for the board's request cost to be visible to a human: a board
659
+ * left open in a pane spent 95% of the fleet's GitHub budget for three and a
660
+ * half hours and nothing on screen said so. Mutates the two windows in place —
661
+ * they are the caller's ring buffers, kept small by this prune.
662
+ */
663
+ export function trackerCostWindow(
664
+ calls: number[],
665
+ free: number[],
666
+ now: number,
667
+ windowMs = 60_000,
668
+ ): { calls: number; free: number } {
669
+ const cutoff = now - windowMs;
670
+ while (calls.length > 0 && calls[0]! < cutoff) calls.shift();
671
+ while (free.length > 0 && free[0]! < cutoff) free.shift();
672
+ return { calls: calls.length, free: free.length };
673
+ }
674
+
675
+ /** How current the label sets behind the lanes are, and what this board is
676
+ * spending to keep them current. A lane rule is only as authoritative as its
677
+ * last successful read, so a stale one says so instead of letting an empty
678
+ * QUEUE read as an empty queue. */
568
679
  function labelFact(snapshot: BoardSnapshot): string {
569
680
  const { readAt, error } = snapshot.labels;
570
- if (error === undefined) return "tracker ok";
681
+ const cost =
682
+ snapshot.trackerCost === undefined
683
+ ? ""
684
+ : ` · ${snapshot.trackerCost.calls}/min${snapshot.trackerCost.free === 0 ? "" : ` (${snapshot.trackerCost.free} free)`}`;
685
+ if (error === undefined) return `tracker ok${cost}`;
571
686
  const detail = error.replace(/\s+/g, " ").slice(0, 80);
572
- if (readAt === 0) return `tracker unread — ${detail}`;
573
- return `tracker stale ${humanDuration(snapshot.now - readAt)} — ${detail}`;
687
+ if (readAt === 0) return `tracker unread${cost} — ${detail}`;
688
+ return `tracker stale ${humanDuration(snapshot.now - readAt)}${cost} — ${detail}`;
574
689
  }
575
690
 
576
691
  function integrationLine(snapshot: BoardSnapshot): string {
@@ -799,36 +914,31 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
799
914
  * under a run row — are invisible to it, and a board built from run rows alone
800
915
  * reports last-run history as current work.
801
916
  *
802
- * "Open issues carrying label L" is a read the tracker port spells exactly
803
- * once, as `listReady`, taking the label from `queueLabel`. Substituting the
804
- * label reuses that one read for the state labels rather than adding a twelfth
805
- * method every future adapter would have to implement for a read-only surface.
806
- * Four `gh` calls in parallel, on the ten-second health cadence and never on
807
- * the one-second redraw path: the same order of cost as the health probe it
808
- * runs beside.
917
+ * One open-issue snapshot per pass, never one list call per label (#203): the
918
+ * four lifecycle sets are derived in memory from it by {@link boardLabelSets},
919
+ * and PARKED openness is read off the same snapshot instead of a per-candidate
920
+ * `issueState` fan-out. The single read rides the conditional-request cache, so
921
+ * an idle board costs no primary rate-limit budget at all. The cadence stays
922
+ * the ten-second health refresh, never the one-second redraw path.
809
923
  *
810
924
  * Never throws. A failed read keeps the previous sets on screen and records
811
925
  * why, because blanking QUEUE and FAILED is a louder lie than showing them
812
926
  * stale — {@link labelFact} puts the staleness in the header.
813
927
  */
814
- async function probeBoardLabels(
928
+ export async function probeBoardLabels(
815
929
  project: ProjectConfig,
816
930
  store: Store,
817
931
  previous?: BoardLabels,
932
+ probeTracker?: Tracker,
818
933
  ): Promise<BoardLabels> {
819
- const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
820
- new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
821
934
  try {
822
- const [queued, inProgress, blocked, failed] = await Promise.all([
823
- openIssuesLabelled(project.queueLabel),
824
- openIssuesLabelled(project.stateLabels.inProgress),
825
- openIssuesLabelled(project.stateLabels.blocked),
826
- openIssuesLabelled(project.stateLabels.failed),
827
- ]);
935
+ const tracker = probeTracker ?? makeTracker(project);
936
+ const open = await tracker.listOpenIssues();
937
+ const { queued, inProgress, blocked, failed } = boardLabelSets(open, project);
828
938
  // PARKED candidates: the newest run per issue, terminal, non-merged, and
829
939
  // wearing no state or queue label. Exactly the rows {@link laneOf} would
830
- // otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit,
831
- // issued in parallel, and cached: a failed read keeps the last set.
940
+ // otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit
941
+ // and cached: a failed read keeps the last set.
832
942
  const labelled = new Set([...queued, ...inProgress, ...blocked, ...failed]);
833
943
  const newestRun = new Map<number, RunRecord>();
834
944
  for (const run of store.recentRuns(project.name, Date.now() - MERGED_HISTORY_MS)) {
@@ -837,14 +947,11 @@ async function probeBoardLabels(
837
947
  const parkedCandidates = [...newestRun.values()].filter(
838
948
  (run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
839
949
  );
840
- const open = new Set<number>();
841
- await Promise.all(
842
- parkedCandidates.map(async (run) => {
843
- const state = await makeTracker(project).issueState(run.issue);
844
- if (state === "open") open.add(run.issue);
845
- }),
846
- );
847
- return { queued, inProgress, blocked, failed, open, readAt: Date.now() };
950
+ // The snapshot IS the set of open issues, so openness needs no per-issue
951
+ // lookup: a candidate whose issue appears in it is open.
952
+ const openNumbers = new Set(open.map((issue) => issue.number));
953
+ const openSet = new Set(parkedCandidates.map((run) => run.issue).filter((n) => openNumbers.has(n)));
954
+ return { queued, inProgress, blocked, failed, open: openSet, readAt: Date.now() };
848
955
  } catch (err) {
849
956
  return {
850
957
  ...(previous ?? UNREAD_LABELS),
@@ -905,6 +1012,81 @@ async function waitForInput(queue: KeyInput[], setWake: (wake?: () => void) => v
905
1012
  return queue.shift() ?? { name: "refresh" };
906
1013
  }
907
1014
 
1015
+ /**
1016
+ * One-shot snapshot for the headless board (#200): the same setup and one round
1017
+ * of probes `runBoard` performs on entry, with the per-row PR re-verification
1018
+ * probe deliberately skipped — a one-shot is a point-in-time read, so there is
1019
+ * no live verdict map to keep warm.
1020
+ */
1021
+ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnapshot> {
1022
+ const cfg = loadConfig();
1023
+ const project = findProject(cfg, projectName);
1024
+ const caps = resolveCaps(project, cfg.defaults);
1025
+ const store: Store = openStore(dbPath());
1026
+ try {
1027
+ const now = Date.now();
1028
+ const [health, labels, planUsage] = await Promise.all([
1029
+ probeBoardHealth(project),
1030
+ probeBoardLabels(project, store),
1031
+ readPlanUsage(caps.planUsage, sharedUsageSource()),
1032
+ ]);
1033
+ return {
1034
+ project,
1035
+ status: statusSnapshotFromStore(project, caps, store, planUsage),
1036
+ health,
1037
+ labels,
1038
+ pr: new Map(),
1039
+ runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
1040
+ now,
1041
+ };
1042
+ } finally {
1043
+ store.close();
1044
+ }
1045
+ }
1046
+
1047
+ interface BoardJsonRun {
1048
+ issue: number;
1049
+ state: string;
1050
+ attempt?: number;
1051
+ prUrl?: string;
1052
+ }
1053
+
1054
+ /**
1055
+ * The headless board's JSON view (#200): one entry per issue per lane, fields
1056
+ * read from the lane's newest run row. Issues the store has never run (queue
1057
+ * members placed by label or hold) carry `"state": "queued"` instead. Every
1058
+ * COLUMN_DEFS lane key is emitted, empty ones included, so consumers never
1059
+ * key-check; keys and entries keep the board's stable render order.
1060
+ */
1061
+ export function boardJson(snapshot: BoardSnapshot): string {
1062
+ // `recentRuns` yields the newest attempt per issue first; indexing keeps one
1063
+ // entry per issue even when a caller hands over a wider run list.
1064
+ const newest = new Map<number, RunRecord>();
1065
+ for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run);
1066
+
1067
+ const lanesByKey = boardLanes(snapshot);
1068
+ const lanes: Record<string, BoardJsonRun[]> = {};
1069
+ for (const { key } of COLUMN_DEFS) {
1070
+ lanes[key] = (lanesByKey.get(key) ?? []).map((issue) => {
1071
+ const run = newest.get(issue);
1072
+ if (run === undefined) {
1073
+ // Label- or hold-placed issues the store never ran. The queue lane's
1074
+ // state is its own word ("queued"); the other label-placed lanes keep
1075
+ // their key as the state, which is exactly what they are.
1076
+ return { issue, state: key === "queue" ? "queued" : key };
1077
+ }
1078
+ const entry: BoardJsonRun = { issue, state: run.state, attempt: run.attempt };
1079
+ if (run.prUrl !== undefined) entry.prUrl = run.prUrl;
1080
+ return entry;
1081
+ });
1082
+ }
1083
+ return JSON.stringify(
1084
+ { project: snapshot.project.name, generatedAt: new Date(snapshot.now).toISOString(), lanes },
1085
+ null,
1086
+ 2,
1087
+ );
1088
+ }
1089
+
908
1090
  export async function runBoard(projectName?: string): Promise<void> {
909
1091
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
910
1092
  throw new Error("board needs an interactive terminal (TTY)");
@@ -920,13 +1102,28 @@ export async function runBoard(projectName?: string): Promise<void> {
920
1102
  let stopping = false;
921
1103
  let help = false;
922
1104
  let notice = "";
1105
+ // One tracker for the whole board, bound to its own call counters (#210):
1106
+ // every request this UI makes is counted where a human can see it, instead
1107
+ // of only being visible to an external shim on `gh`.
1108
+ const trackerCalls: number[] = [];
1109
+ const trackerFree: number[] = [];
1110
+ const boardTracker = makeTracker(project, undefined, {
1111
+ onCall: () => trackerCalls.push(Date.now()),
1112
+ onNotModified: () => trackerFree.push(Date.now()),
1113
+ });
923
1114
  let [health, labels, planUsage] = await Promise.all([
924
1115
  probeBoardHealth(project),
925
- probeBoardLabels(project, store),
1116
+ probeBoardLabels(project, store, undefined, boardTracker),
926
1117
  readPlanUsage(caps.planUsage, sharedUsageSource()),
927
1118
  ]);
928
1119
  let healthAt = Date.now();
929
1120
  let healthRefresh: Promise<void> | undefined;
1121
+ // The tracker read is gated separately from health and the plan allowance:
1122
+ // those two are local reads that cost no API traffic, so they stay on the
1123
+ // base cadence while this one backs off across unchanged passes.
1124
+ let labelAt = Date.now();
1125
+ let labelDelay = LABEL_REFRESH_MS;
1126
+ let labelRefresh: Promise<void> | undefined;
930
1127
  // Live re-verification of pushed rows (#173): cached per run id, refreshed on
931
1128
  // the slow PR cadence so a `gh` call per pushed row does not ride the 1 Hz
932
1129
  // repaint or the 10 s health gate.
@@ -956,14 +1153,12 @@ export async function runBoard(projectName?: string): Promise<void> {
956
1153
  healthAt = now;
957
1154
  healthRefresh = Promise.all([
958
1155
  probeBoardHealth(project),
959
- probeBoardLabels(project, store, labels),
960
1156
  // On the health cadence, not the 1s repaint: the provider read is a
961
1157
  // subprocess, and an allowance does not move at 1 Hz.
962
1158
  readPlanUsage(caps.planUsage, sharedUsageSource()),
963
1159
  ])
964
- .then(([nextHealth, nextLabels, nextPlanUsage]) => {
1160
+ .then(([nextHealth, nextPlanUsage]) => {
965
1161
  health = nextHealth;
966
- labels = nextLabels;
967
1162
  planUsage = nextPlanUsage;
968
1163
  enqueue(queue, { name: "refresh" }, wake);
969
1164
  })
@@ -974,6 +1169,21 @@ export async function runBoard(projectName?: string): Promise<void> {
974
1169
  healthRefresh = undefined;
975
1170
  });
976
1171
  }
1172
+ if (now - labelAt >= labelDelay && labelRefresh === undefined) {
1173
+ labelAt = now;
1174
+ labelRefresh = probeBoardLabels(project, store, labels, boardTracker)
1175
+ .then((nextLabels) => {
1176
+ labelDelay = nextLabelDelayMs(labelDelay, boardLabelsEqual(labels, nextLabels));
1177
+ labels = nextLabels;
1178
+ enqueue(queue, { name: "refresh" }, wake);
1179
+ })
1180
+ .catch((err: unknown) => {
1181
+ notice = `label refresh failed: ${err instanceof Error ? err.message : String(err)}`;
1182
+ })
1183
+ .finally(() => {
1184
+ labelRefresh = undefined;
1185
+ });
1186
+ }
977
1187
  // #173: re-verify pushed rows against the tracker on their own slow
978
1188
  // cadence, so red-shown-as-green and a moved head surface without paying
979
1189
  // for the read every second. A failed probe leaves the last verdicts in
@@ -984,10 +1194,9 @@ export async function runBoard(projectName?: string): Promise<void> {
984
1194
  (run) => (run.state === "pushed-green" || run.state === "pushed-pending") && run.prUrl !== undefined && run.headSha !== undefined,
985
1195
  );
986
1196
  prProbeRun = (async () => {
987
- const tracker = makeTracker(project);
988
1197
  await Promise.all(
989
1198
  rows.map(async (run) => {
990
- const verified = await tracker.verifyPr(run.prUrl!, run.headSha!);
1199
+ const verified = await boardTracker.verifyPr(run.prUrl!, run.headSha!);
991
1200
  if (verified !== undefined) prProbe.set(run.id, { status: verified.status, reason: verified.reason });
992
1201
  }),
993
1202
  );
@@ -1006,6 +1215,7 @@ export async function runBoard(projectName?: string): Promise<void> {
1006
1215
  labels,
1007
1216
  pr: prProbe,
1008
1217
  runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
1218
+ trackerCost: trackerCostWindow(trackerCalls, trackerFree, now),
1009
1219
  now,
1010
1220
  };
1011
1221
  normalizeCursor(snapshot, cursor);
@@ -1017,6 +1227,9 @@ export async function runBoard(projectName?: string): Promise<void> {
1017
1227
  });
1018
1228
  const name = key.name ?? key.sequence;
1019
1229
  if (name === "refresh" || name === "resize") continue;
1230
+ // Past this point the key came from a person, not the refresh timer:
1231
+ // someone watching the board gets the base cadence back immediately.
1232
+ labelDelay = LABEL_REFRESH_MS;
1020
1233
  if (name === "?" || key.sequence === "?") {
1021
1234
  help = !help;
1022
1235
  continue;
@@ -1037,6 +1250,7 @@ export async function runBoard(projectName?: string): Promise<void> {
1037
1250
  }
1038
1251
  if (name === "r") {
1039
1252
  healthAt = 0;
1253
+ labelAt = 0;
1040
1254
  notice = "refresh requested";
1041
1255
  continue;
1042
1256
  }
@@ -1110,7 +1324,9 @@ export async function runBoard(projectName?: string): Promise<void> {
1110
1324
  notice = "unblock is available for blocked, failed or orphaned issues";
1111
1325
  } else {
1112
1326
  notice = await unblock(project, cardIssue(card));
1327
+ // An unblock just changed labels: re-read both gates now.
1113
1328
  healthAt = 0;
1329
+ labelAt = 0;
1114
1330
  }
1115
1331
  }
1116
1332
  }
@@ -10,12 +10,8 @@
10
10
  * banner can `retrofit` one at a classified cut before migrating.
11
11
  */
12
12
 
13
- import { randomUUID } from "node:crypto";
14
13
  import {
15
- constants,
16
- copyFileSync,
17
14
  existsSync,
18
- linkSync,
19
15
  mkdirSync,
20
16
  readFileSync,
21
17
  readdirSync,
@@ -23,6 +19,7 @@ import {
23
19
  writeFileSync,
24
20
  } from "node:fs";
25
21
  import { basename, dirname, join } from "node:path";
22
+ import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
26
23
  import { stateDir } from "./config.ts";
27
24
 
28
25
  /**
@@ -315,32 +312,6 @@ export function briefBackupDir(): string {
315
312
  return join(stateDir(), "backups", "briefs");
316
313
  }
317
314
 
318
- function backupTimestamp(): string {
319
- return new Date().toISOString().replace(/[:.]/g, "-");
320
- }
321
-
322
- function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
323
- mkdirSync(backupRoot, { recursive: true });
324
- const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
325
- copyFileSync(source, temporary, constants.COPYFILE_EXCL);
326
- try {
327
- for (let suffix = 0; ; suffix += 1) {
328
- const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
329
- try {
330
- // Linking a complete temp file publishes the backup atomically without
331
- // overwriting a backup created concurrently at the same millisecond.
332
- linkSync(temporary, destination);
333
- return destination;
334
- } catch (err) {
335
- if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
336
- throw err;
337
- }
338
- }
339
- } finally {
340
- unlinkSync(temporary);
341
- }
342
- }
343
-
344
315
  /**
345
316
  * Moves only conductor's timestamp-shaped legacy sidecar backups into state.
346
317
  * Unknown `.bak` files remain operator-owned. Copy-before-unlink also works
@@ -282,6 +282,16 @@ Not yours to relax:
282
282
  not the branch. A settled run means nobody else is coming:
283
283
  waiting is not caution, it is a stall.
284
284
 
285
+ **You never sleep, poll, or wait inside a tick.** No `sleep`, no retry loop, no
286
+ "watch this PR until green" — a tool call that exists to pass time is a tool
287
+ call that blocks your operator's messages. Anything that needs waiting for is
288
+ either a watch (`decision open --resolves-when pr-checks-green:<url>`,
289
+ `pr-mergeable:<url>`, `pr-merged:<url>`, `issue-closed:<n>`,
290
+ `npm-version:<pkg>@<version>`, `rate-limit-reset:github`) or a subagent's
291
+ problem. The harness refuses further tool calls once a turn exceeds its budget
292
+ or an operator message is queued — end the turn and let the next tick act on
293
+ `[CONDITION MET]`.
294
+
285
295
  ## Your verb surface
286
296
 
287
297
  You can reach `gh`, and you do not publish with it. Everything below happens
@@ -420,8 +430,9 @@ omp-conductor decision withdraw <id> --reason "<why it stopped mattering>"
420
430
  ```
421
431
 
422
432
  Use `--resolves-when` whenever the answer only becomes actionable once something
423
- observable happens: `pr-merged:<url>`, `issue-closed:<n>`,
424
- `npm-version:<pkg>@<version>`. The daemon checks it for you and flags the row as
433
+ observable happens: `pr-merged:<url>`, `pr-checks-green:<url>`,
434
+ `pr-mergeable:<url>`, `issue-closed:<n>`, `npm-version:<pkg>@<version>`,
435
+ `rate-limit-reset:github`. The daemon checks it for you and flags the row as
425
436
  `[CONDITION MET — act on this now]` in your tick digest, so a parked question
426
437
  wakes up on its own instead of waiting for you to think of it.
427
438