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/types.ts
CHANGED
|
@@ -42,6 +42,10 @@ export interface Caps {
|
|
|
42
42
|
/** Parallel omp sessions. Two by default: on a small self-hosted runner pool
|
|
43
43
|
* a third worker would starve its own PR checks. */
|
|
44
44
|
maxConcurrentWorkers: number;
|
|
45
|
+
/** Max live workers per repository. 1 by default: the mirror, branch-protection
|
|
46
|
+
* staleness and shared CI egress are all per-repo collision domains (#186), so
|
|
47
|
+
* extra slots should land on other repos. */
|
|
48
|
+
maxConcurrentWorkersPerRepo: number;
|
|
45
49
|
/**
|
|
46
50
|
* Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock
|
|
47
51
|
* still apply). `0` is a hard stop — deliberate, not "unset".
|
|
@@ -479,6 +483,10 @@ export interface ProjectConfig {
|
|
|
479
483
|
tracker: { kind: "github"; repo: string };
|
|
480
484
|
/** The one label that means "a human has signed this off as agent-ready". */
|
|
481
485
|
queueLabel: string;
|
|
486
|
+
/** Routable-candidate count below which the tick prompt tells the orchestrator
|
|
487
|
+
* to groom the queue. Optional; defaults to {@link DEFAULT_GROOM_BELOW} in
|
|
488
|
+
* orchestrator-tick.ts. */
|
|
489
|
+
groomBelow?: number;
|
|
482
490
|
/** Labels the dispatcher writes back so the tracker alone shows live state
|
|
483
491
|
* to a human who never opens the daemon's logs. */
|
|
484
492
|
stateLabels: { inProgress: string; blocked: string; failed: string };
|
|
@@ -686,6 +694,14 @@ export interface OpenCloser {
|
|
|
686
694
|
*/
|
|
687
695
|
export interface Tracker {
|
|
688
696
|
listReady(): Promise<ReadyIssue[]>;
|
|
697
|
+
/**
|
|
698
|
+
* Every open issue in the tracker repo (never PRs), labels included: one
|
|
699
|
+
* read that covers all lifecycle-label queries of a pass, so consumers
|
|
700
|
+
* needing several label sets pay one fetch instead of one per label, and
|
|
701
|
+
* openness itself is answerable without a per-issue lookup (#203).
|
|
702
|
+
* Complete — follows pagination to the end.
|
|
703
|
+
*/
|
|
704
|
+
listOpenIssues(): Promise<ReadyIssue[]>;
|
|
689
705
|
addLabel(issue: number, label: string): Promise<void>;
|
|
690
706
|
removeLabel(issue: number, label: string): Promise<void>;
|
|
691
707
|
comment(issue: number, body: string): Promise<void>;
|
|
@@ -903,6 +919,11 @@ export interface RunRecord {
|
|
|
903
919
|
* exactly as an unflagged one does, and the flags are evidence for whoever
|
|
904
920
|
* reviews the PR. Absent means the audit found nothing, or never ran. */
|
|
905
921
|
settlementFlags?: SettlementFlag[];
|
|
922
|
+
/** The worker's settlement report text for this attempt, verbatim. Persisted
|
|
923
|
+
* on every terminal update so the next attempt can pool its disclosures
|
|
924
|
+
* (#199). Absent means the run predates the column, or was killed before the
|
|
925
|
+
* worker returned a report. */
|
|
926
|
+
report?: string;
|
|
906
927
|
/**
|
|
907
928
|
* Why this run ended badly, and what the daemon did about it (#132). Absent
|
|
908
929
|
* means the sweep has not looked at the row yet — never "nothing was wrong":
|
|
@@ -923,6 +944,7 @@ export type AdmissionHoldReason =
|
|
|
923
944
|
| "continuations"
|
|
924
945
|
| "parent-lookup-error"
|
|
925
946
|
| "sibling-active"
|
|
947
|
+
| "repo-active"
|
|
926
948
|
| "open-pr-lookup-error"
|
|
927
949
|
| "open-pr"
|
|
928
950
|
| "unsalvaged-wip"
|
|
@@ -1134,10 +1156,28 @@ export interface DecisionDraft {
|
|
|
1134
1156
|
at: number;
|
|
1135
1157
|
}
|
|
1136
1158
|
|
|
1159
|
+
/** One decided GitHub label change waiting to be projected (#201). */
|
|
1160
|
+
export interface LabelOp {
|
|
1161
|
+
id: number;
|
|
1162
|
+
project: string;
|
|
1163
|
+
issue: number;
|
|
1164
|
+
op: "add" | "remove";
|
|
1165
|
+
label: string;
|
|
1166
|
+
createdAt: number;
|
|
1167
|
+
attempts: number;
|
|
1168
|
+
nextAttemptAt: number;
|
|
1169
|
+
lastError?: string;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1137
1172
|
/**
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1173
|
+
* Dispatch state is store-authoritative; GitHub labels are a retried
|
|
1174
|
+
* write-behind projection of it (#201). `isEligible` still consults the label
|
|
1175
|
+
* set a candidate *effectively* carries — physical labels overlaid with the
|
|
1176
|
+
* outbox's pending ops — so a label GitHub refuses to move cannot strand an
|
|
1177
|
+
* issue the daemon has already decided to move on. A store loss no longer
|
|
1178
|
+
* rebuilds the world: it also forgets what labels the tracker was told to
|
|
1179
|
+
* carry, so the operator reconciles by hand, which is the only safe move once
|
|
1180
|
+
* the two disagree.
|
|
1141
1181
|
*/
|
|
1142
1182
|
export interface Store {
|
|
1143
1183
|
createRun(r: Omit<RunRecord, "id">): RunRecord;
|
|
@@ -1170,6 +1210,11 @@ export interface Store {
|
|
|
1170
1210
|
* tail` resolves an issue number to a transcript through this; the number is
|
|
1171
1211
|
* what an operator has, the run id is not. */
|
|
1172
1212
|
latestRun(project: string, issue: number): RunRecord | undefined;
|
|
1213
|
+
/** Every attempt of one issue that settled with a report, in attempt order.
|
|
1214
|
+
* The settlement audit pools these as prior disclosures when a later
|
|
1215
|
+
* attempt is reconciled (#199). Rows with no report (pre-#199, or killed
|
|
1216
|
+
* before the worker returned) are skipped. */
|
|
1217
|
+
attemptReports(project: string, issue: number): { attempt: number; report: string }[];
|
|
1173
1218
|
runsStartedSince(project: string, sinceEpochMs: number): number;
|
|
1174
1219
|
spendSince(project: string, sinceEpochMs: number): number;
|
|
1175
1220
|
/** Idempotence guard so a retry loop cannot page a human repeatedly for the
|
|
@@ -1189,6 +1234,15 @@ export interface Store {
|
|
|
1189
1234
|
/** Start the cooldown only after a tick carrying these signals was sent. */
|
|
1190
1235
|
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
|
|
1191
1236
|
markNotified(key: string): void;
|
|
1237
|
+
/** Record one observed GitHub rate-limit refusal (the tracker's hook). Rows
|
|
1238
|
+
* older than 24h are pruned in the same write (#198). */
|
|
1239
|
+
recordGhRefusal?(at: number): void;
|
|
1240
|
+
/** Refusals observed at or after `sinceMs` — `status` reads the last 5m. */
|
|
1241
|
+
ghRefusalsSince?(sinceMs: number): { count: number; latestAt?: number };
|
|
1242
|
+
/** Count one daemon-held `gh` call for the UTC day (`YYYY-MM-DD`). */
|
|
1243
|
+
bumpGhCalls?(day: string, source: string): void;
|
|
1244
|
+
/** The tracked call counts for a UTC day, per source. */
|
|
1245
|
+
ghCallsToday?(day: string): { source: string; calls: number }[];
|
|
1192
1246
|
/**
|
|
1193
1247
|
* Persist a rendered report `pending`, before anything is sent — the whole
|
|
1194
1248
|
* point of #123 is that an undelivered report is a queryable row rather than
|
|
@@ -1283,6 +1337,33 @@ export interface Store {
|
|
|
1283
1337
|
releaseMergeLock(project: string, holder: string): void;
|
|
1284
1338
|
/** The live lock, for `status` and for tests. */
|
|
1285
1339
|
mergeLock(project: string): MergeLock | undefined;
|
|
1340
|
+
/**
|
|
1341
|
+
* Append decided GitHub label changes to the projection outbox (#201). One
|
|
1342
|
+
* transaction: either every op lands as a pending row or none do, so the
|
|
1343
|
+
* daemon's record of its own intent cannot disagree with itself. Ops for one
|
|
1344
|
+
* issue apply in enqueue (id) order, which is what makes a swap atomic — the
|
|
1345
|
+
* remove lands before the add exactly as enqueued.
|
|
1346
|
+
*/
|
|
1347
|
+
enqueueLabelOps(
|
|
1348
|
+
project: string,
|
|
1349
|
+
ops: readonly { issue: number; op: "add" | "remove"; label: string }[],
|
|
1350
|
+
): void;
|
|
1351
|
+
/** Unapplied ops whose retry backoff has elapsed, oldest first (#201). */
|
|
1352
|
+
pendingLabelOps(project: string, now: number): LabelOp[];
|
|
1353
|
+
/** Every unapplied op for one issue, enqueue order — the eligibility overlay
|
|
1354
|
+
* reads this so a stale GitHub label cannot outlive the daemon's intent
|
|
1355
|
+
* (#201). */
|
|
1356
|
+
pendingLabelOpsFor(project: string, issue: number): LabelOp[];
|
|
1357
|
+
/** The op applied; forget it. */
|
|
1358
|
+
settleLabelOp(id: number): void;
|
|
1359
|
+
/** The op failed; count the attempt and park it behind `nextAttemptAt`. Pass
|
|
1360
|
+
* `countAttempt: false` for a refusal that is not the op's fault (a shared
|
|
1361
|
+
* rate limit), so it does not burn the backoff escalation (#208). */
|
|
1362
|
+
deferLabelOp(id: number, error: string, nextAttemptAt: number, countAttempt?: boolean): void;
|
|
1363
|
+
/** Unapplied ops still owed — `status` shows the projection lag (#201). */
|
|
1364
|
+
countPendingLabelOps(project: string): number;
|
|
1365
|
+
/** `createdAt` of the oldest unapplied op, or `undefined` for none. */
|
|
1366
|
+
oldestPendingLabelOpAt(project: string): number | undefined;
|
|
1286
1367
|
close(): void;
|
|
1287
1368
|
}
|
|
1288
1369
|
|
|
@@ -1311,6 +1392,7 @@ export interface Escalation {
|
|
|
1311
1392
|
*/
|
|
1312
1393
|
export const DEFAULT_CAPS: Caps = {
|
|
1313
1394
|
maxConcurrentWorkers: 2,
|
|
1395
|
+
maxConcurrentWorkersPerRepo: 1,
|
|
1314
1396
|
dailySpendUsd: 25,
|
|
1315
1397
|
// Off unless an operator names a window. A default threshold would need a
|
|
1316
1398
|
// default window id, and guessing which allowance a fleet lives on is how a
|
package/src/unblock.ts
CHANGED
|
@@ -15,18 +15,27 @@
|
|
|
15
15
|
* dispatcher writes labels with, and the brief's rule stays absolute. That
|
|
16
16
|
* absoluteness is worth more than the exception it replaces: orphan detection
|
|
17
17
|
* is only trustworthy while every state label on the tracker was written by
|
|
18
|
-
* this package.
|
|
18
|
+
* this package. The re-queue is the same port and the same principle: the
|
|
19
|
+
* queue label goes back on by default (#184) so an answered block is
|
|
20
|
+
* dispatchable again, and it stays off under `--no-requeue` or while any run
|
|
21
|
+
* for the issue is still active.
|
|
19
22
|
*
|
|
20
|
-
* Nothing here writes to the
|
|
21
|
-
* omission. `RunState` describes what a worker process did; an answer is the
|
|
23
|
+
* Nothing here writes to the run history, and that is a decision rather than
|
|
24
|
+
* an omission. `RunState` describes what a worker process did; an answer is the
|
|
22
25
|
* one event that happens outside every run, so no member fits it — folding it
|
|
23
26
|
* into `merged` or `killed` would make `status` describe a run that never
|
|
24
|
-
* reached either.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* reached either. The one store write is the label projection outbox (#201):
|
|
28
|
+
* a decided label change is durable locally and projected with retry, so a 403
|
|
29
|
+
* defers it instead of throwing the verb, and the daemon's next tick applies it
|
|
30
|
+
* even if this process dies before it does. Eligibility is read off the label
|
|
31
|
+
* set an issue effectively carries — physical labels overlaid with that outbox —
|
|
32
|
+
* and never off a run row, so the store has nothing else to say here. Leaving
|
|
33
|
+
* history alone keeps both budgets honest: a block consumes an operational
|
|
34
|
+
* continuation, while a real implementation failure consumes the separate
|
|
35
|
+
* failed-attempt budget.
|
|
28
36
|
*/
|
|
29
37
|
|
|
38
|
+
import { projectLabels } from "./label-projection.ts";
|
|
30
39
|
import { LIVE_STATES } from "./store.ts";
|
|
31
40
|
import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
|
|
32
41
|
|
|
@@ -44,11 +53,32 @@ export interface UnblockOutcome {
|
|
|
44
53
|
* to "will the dispatcher hold this issue as issue-active?" that #178
|
|
45
54
|
* found this verb guessing at. */
|
|
46
55
|
active: boolean;
|
|
56
|
+
/** Set when a live worker (a claimed/running run) is on the issue. The
|
|
57
|
+
* dispatcher holds a worker-backed run as issue-active unconditionally,
|
|
58
|
+
* while worker-free pushed occupancy can bypass it (#175) — so this is
|
|
59
|
+
* the signal the queue label and the in-flight wording follow. */
|
|
60
|
+
live?: true;
|
|
47
61
|
/** Set when nothing was cleared because the newest attempt's work exists
|
|
48
62
|
* only in its worktree. Carries the salvage failure verbatim. */
|
|
49
63
|
refused?: string;
|
|
50
64
|
/** Set when `--force` recorded an operator's acceptance of that loss. */
|
|
51
65
|
forced?: true;
|
|
66
|
+
/** Set when the queue label was re-added (the default); absent on
|
|
67
|
+
* --no-requeue, on the refusal path, and when a live worker (claimed or
|
|
68
|
+
* running) is still on the issue. A pushed-green/pushed-pending occupancy
|
|
69
|
+
* is worker-free, so the label is still restored there (#175 bypasses it;
|
|
70
|
+
* `isEligible` needs the label once the PR resolves). */
|
|
71
|
+
requeued?: true;
|
|
72
|
+
/** Set when `--no-requeue` skipped the queue-label re-add. */
|
|
73
|
+
requeueSkipped?: true;
|
|
74
|
+
/** Set when the tracker refused one or more of this verb's label ops, so
|
|
75
|
+
* the label sync is owed rather than done: the intended label state is
|
|
76
|
+
* durable in the store and the daemon retries it (#201). Safety is
|
|
77
|
+
* preserved either way, but the issue is only claimable once the queue
|
|
78
|
+
* label itself has landed — `listReady` reads the tracker by label, so a
|
|
79
|
+
* queue-label add that has not been applied is invisible to dispatch. The
|
|
80
|
+
* number is what is still pending. */
|
|
81
|
+
labelSyncQueued?: number;
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
/**
|
|
@@ -90,13 +120,24 @@ export async function unblockIssue(
|
|
|
90
120
|
tracker: Tracker,
|
|
91
121
|
store: Store,
|
|
92
122
|
issue: number,
|
|
93
|
-
opts: { force?: boolean } = {},
|
|
123
|
+
opts: { force?: boolean; requeue?: boolean } = {},
|
|
94
124
|
): Promise<UnblockOutcome> {
|
|
125
|
+
const requeue = opts.requeue !== false;
|
|
95
126
|
// Read before any label is touched: terminality is the whole of the argument
|
|
96
127
|
// for clearing in-progress, so the row that carries it decides the set.
|
|
97
128
|
const latest = store.latestRun(project.name, issue);
|
|
98
129
|
const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
|
|
130
|
+
// Two different occupancies, and the label decisions each follow its own:
|
|
131
|
+
// `active` (ACTIVE_STATES) is what *occupies the issue* — a pushed-green or
|
|
132
|
+
// pushed-pending run has no worker process but its PR is live, so dispatch
|
|
133
|
+
// must not start a fresh attempt behind it. `live` (LIVE_STATES) is what
|
|
134
|
+
// *holds a worker process* (claimed/running). The queue label follows
|
|
135
|
+
// `live`: the dispatcher only holds a claim as issue-active for a
|
|
136
|
+
// worker-backed run (#175 bypasses worker-free pushed-green rows), so an
|
|
137
|
+
// issue whose occupancy is pushed-only is genuinely re-queueable and
|
|
138
|
+
// `isEligible` requires the label once that PR resolves closed-unmerged.
|
|
99
139
|
const active = store.activeRuns(project.name).some((r) => r.issue === issue);
|
|
140
|
+
const live = store.liveRuns(project.name).some((r) => r.issue === issue);
|
|
100
141
|
const counts = {
|
|
101
142
|
attemptsUsed: store.attemptsFor(project.name, issue),
|
|
102
143
|
failuresUsed: store.failuresFor(project.name, issue),
|
|
@@ -123,20 +164,66 @@ export async function unblockIssue(
|
|
|
123
164
|
if (held !== undefined) store.updateRun(held.id, { salvageAckAt: Date.now() });
|
|
124
165
|
|
|
125
166
|
const cleared: string[] = [];
|
|
167
|
+
const ops: { issue: number; op: "add" | "remove"; label: string }[] = [];
|
|
126
168
|
for (const label of new Set([
|
|
127
169
|
project.stateLabels.blocked,
|
|
128
170
|
project.stateLabels.failed,
|
|
129
171
|
...(terminal ? [project.stateLabels.inProgress] : []),
|
|
130
172
|
])) {
|
|
131
|
-
|
|
173
|
+
ops.push({ issue, op: "remove", label });
|
|
132
174
|
cleared.push(label);
|
|
133
175
|
}
|
|
134
176
|
|
|
177
|
+
// The re-queue half of the verb. Clearing the state labels makes the issue
|
|
178
|
+
// *eligible*, but the dispatcher never sees an issue that does not carry the
|
|
179
|
+
// queue label — without this the unblock ends in the same alive-but-inert
|
|
180
|
+
// state it exists to end (#184). The label goes back on by default;
|
|
181
|
+
// `--no-requeue` is the "about to close it" case and leaves dispatch alone.
|
|
182
|
+
// Never under a *live worker*: the dispatcher holds a worker-backed run as
|
|
183
|
+
// issue-active anyway, and restoring the label under one is exactly the
|
|
184
|
+
// #178 inverted state. A pushed-green/pushed-pending run is worker-free —
|
|
185
|
+
// its PR is live but no process writes to its branch — so a pushed
|
|
186
|
+
// continuation still gets the label: the dispatcher bypasses worker-free
|
|
187
|
+
// pushed-green rows (#175), and `isEligible` requires the queue label once
|
|
188
|
+
// the PR resolves closed-unmerged.
|
|
189
|
+
let requeued: true | undefined;
|
|
190
|
+
let requeueSkipped: true | undefined;
|
|
191
|
+
let labelSyncQueued: number | undefined;
|
|
192
|
+
if (!live) {
|
|
193
|
+
if (requeue) {
|
|
194
|
+
ops.push({ issue, op: "add", label: project.queueLabel });
|
|
195
|
+
requeued = true;
|
|
196
|
+
} else {
|
|
197
|
+
requeueSkipped = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Apply through the same projector the daemon drains (#201): the clears and
|
|
202
|
+
// the queue restore land in enqueue order, and a tracker that refuses (403,
|
|
203
|
+
// rate limit) defers them with backoff instead of throwing the verb — which
|
|
204
|
+
// is the concrete #184/#198 kill: `unblock` stops breaking on a 403. The
|
|
205
|
+
// intended label state is durable; the daemon converges the tracker. The
|
|
206
|
+
// issue only becomes claimable once the queue label itself lands —
|
|
207
|
+
// `listReady` queries by label, so a pending queue-label add is invisible to
|
|
208
|
+
// dispatch until projection succeeds.
|
|
209
|
+
if (ops.length > 0) {
|
|
210
|
+
store.enqueueLabelOps(project.name, ops);
|
|
211
|
+
await projectLabels(store, tracker, project);
|
|
212
|
+
// How much of THIS issue's sync is still owed (not the whole fleet's outbox):
|
|
213
|
+
// a backlog on other issues is not this verb's lag to report.
|
|
214
|
+
const stillPending = store.pendingLabelOpsFor(project.name, issue).length;
|
|
215
|
+
if (stillPending > 0) labelSyncQueued = stillPending;
|
|
216
|
+
}
|
|
217
|
+
|
|
135
218
|
return {
|
|
136
219
|
cleared,
|
|
137
220
|
...counts,
|
|
221
|
+
...(live ? { live: true as const } : {}),
|
|
138
222
|
...(latest === undefined ? {} : { latest }),
|
|
139
223
|
...(held === undefined ? {} : { forced: true as const }),
|
|
224
|
+
...(requeued === undefined ? {} : { requeued }),
|
|
225
|
+
...(requeueSkipped === undefined ? {} : { requeueSkipped }),
|
|
226
|
+
...(labelSyncQueued === undefined ? {} : { labelSyncQueued }),
|
|
140
227
|
};
|
|
141
228
|
}
|
|
142
229
|
|
|
@@ -175,6 +262,15 @@ export function formatUnblock(
|
|
|
175
262
|
}
|
|
176
263
|
|
|
177
264
|
const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
|
|
265
|
+
// When the tracker refused the sync, NO label is provably applied — the
|
|
266
|
+
// "restored / eligible again" claims below would be a lie, so they branch to
|
|
267
|
+
// the queued/retry wording instead (#201).
|
|
268
|
+
const labelsPending = o.labelSyncQueued !== undefined;
|
|
269
|
+
if (o.labelSyncQueued !== undefined) {
|
|
270
|
+
lines.push(
|
|
271
|
+
` label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
178
274
|
if (o.forced === true) {
|
|
179
275
|
lines.push(
|
|
180
276
|
` forced the unsalvaged worktree was accepted as lost or already recovered by hand — ` +
|
|
@@ -191,9 +287,15 @@ export function formatUnblock(
|
|
|
191
287
|
}
|
|
192
288
|
|
|
193
289
|
if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
|
|
290
|
+
// The newest run is live, so `active` is necessarily true and the queue
|
|
291
|
+
// label was NOT re-added (unblockIssue only re-queues when no run for the
|
|
292
|
+
// issue is active). Say that, like the sibling-active branch below does —
|
|
293
|
+
// the label outcome is part of the contract (#184), and a live newest run
|
|
294
|
+
// reaches this branch instead of `else if (o.active)`.
|
|
194
295
|
lines.push(
|
|
195
296
|
` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
|
|
196
297
|
`"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
|
|
298
|
+
` queue "${project.queueLabel}" not restored — a run is still active; re-run unblock once it settles`,
|
|
197
299
|
);
|
|
198
300
|
} else if (o.failuresUsed >= caps.maxAttemptsPerIssue) {
|
|
199
301
|
lines.push(
|
|
@@ -218,25 +320,59 @@ export function formatUnblock(
|
|
|
218
320
|
// instead of promising a re-claim the next tick withholds.
|
|
219
321
|
lines.push(
|
|
220
322
|
latest.prUrl === undefined
|
|
221
|
-
?
|
|
222
|
-
|
|
323
|
+
? labelsPending
|
|
324
|
+
? ` next tick label sync queued (${o.labelSyncQueued} pending) — the daemon retries; eligibility is recorded in the store, not yet on the tracker`
|
|
325
|
+
: o.requeued === true
|
|
326
|
+
? ` next tick eligible again — "${project.queueLabel}" restored (no-op if it was already present; ` +
|
|
327
|
+
"the dispatcher still applies its open-PR check at claim time)"
|
|
328
|
+
: ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
|
|
329
|
+
`(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
|
|
223
330
|
: ` next tick eligible as a continuation of ${latest.prUrl} — the pushed run stays active until ` +
|
|
224
331
|
"that PR resolves; dispatch continues on its branch",
|
|
225
332
|
);
|
|
226
333
|
} else if (o.active) {
|
|
227
|
-
// A run other than the newest
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
334
|
+
// A run other than the newest still occupies the issue. Two shapes hide
|
|
335
|
+
// under `active`, and they demand opposite reports: a *live worker*
|
|
336
|
+
// (claimed/running) holds the issue unconditionally — #178's misleading
|
|
337
|
+
// case — while worker-free pushed runs (pushed-green/pushed-pending) are
|
|
338
|
+
// the continuation shape, because #175 lets the dispatcher admit
|
|
339
|
+
// pushed-green rows whose workers are gone. The queue label follows the
|
|
340
|
+
// same split: off under a live worker, restored under pushed-only
|
|
341
|
+
// occupancy.
|
|
342
|
+
if (o.live === true) {
|
|
343
|
+
lines.push(
|
|
344
|
+
` in flight a live worker is still on this issue, so the dispatcher holds it until that run settles — ` +
|
|
345
|
+
`nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
|
|
346
|
+
` queue "${project.queueLabel}" not restored — a live worker is still on this issue; re-run unblock once it settles`,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(
|
|
350
|
+
` next tick no live worker is on this issue — its active run(s) are worker-free pushes; ` +
|
|
351
|
+
"dispatch continues their branch once its holds and open-PR check clear",
|
|
352
|
+
);
|
|
353
|
+
if (labelsPending) {
|
|
354
|
+
lines.push(
|
|
355
|
+
` queue label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`,
|
|
356
|
+
);
|
|
357
|
+
} else if (o.requeued === true) {
|
|
358
|
+
lines.push(` queue "${project.queueLabel}" restored (no-op if it was already present)`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
234
361
|
} else {
|
|
235
362
|
lines.push(
|
|
236
|
-
|
|
237
|
-
|
|
363
|
+
labelsPending
|
|
364
|
+
? ` next tick label sync queued (${o.labelSyncQueued} pending) — the daemon retries; the intended labels are durable, and the issue is claimable once they land`
|
|
365
|
+
: o.requeued === true
|
|
366
|
+
? ` next tick eligible again — "${project.queueLabel}" restored (no-op if it was already present; ` +
|
|
367
|
+
"the dispatcher still applies its open-PR check at claim time)"
|
|
368
|
+
: ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
|
|
369
|
+
"(the dispatcher still applies its open-PR check at claim time)",
|
|
238
370
|
);
|
|
239
371
|
}
|
|
240
372
|
|
|
373
|
+
if (o.requeueSkipped === true) {
|
|
374
|
+
lines.push(` queue "${project.queueLabel}" left untouched (--no-requeue)`);
|
|
375
|
+
}
|
|
376
|
+
|
|
241
377
|
return lines.join("\n");
|
|
242
378
|
}
|
package/src/upgrade.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
|
|
2
3
|
import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
|
|
3
4
|
import { setPaused, statusSnapshot } from "./daemon.ts";
|
|
4
5
|
import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
|
|
5
6
|
import { livingDaemon, restartDaemon } from "./lifecycle.ts";
|
|
6
|
-
import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
|
|
7
|
+
import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
|
|
7
8
|
import { renderBriefForProject } from "./setup.ts";
|
|
8
9
|
import {
|
|
9
10
|
STAGED_SERVICE_NAME,
|
|
@@ -68,7 +69,7 @@ async function runCommand(command: string, args: readonly string[]): Promise<Upg
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
const DEFAULT_DEPS: UpgradeDeps = {
|
|
72
|
+
export const DEFAULT_DEPS: UpgradeDeps = {
|
|
72
73
|
run: runCommand,
|
|
73
74
|
snapshot: statusSnapshot,
|
|
74
75
|
layers: fleetLayers,
|
|
@@ -84,7 +85,7 @@ const DEFAULT_DEPS: UpgradeDeps = {
|
|
|
84
85
|
const daemon = livingDaemon();
|
|
85
86
|
return daemon === undefined ? { running: false } : { running: true, project: daemon.project };
|
|
86
87
|
},
|
|
87
|
-
setPaused,
|
|
88
|
+
setPaused: (v) => setPaused(v, { source: "upgrade", reason: "upgrade, draining" }),
|
|
88
89
|
restartDaemon: async () => {
|
|
89
90
|
await restartDaemon({});
|
|
90
91
|
},
|
|
@@ -213,17 +214,43 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
|
|
|
213
214
|
];
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
async function waitForDrain(deps: UpgradeDeps, project?: string): Promise<void> {
|
|
217
|
+
async function waitForDrain(deps: UpgradeDeps, project?: string, deadlineAt?: number): Promise<void> {
|
|
217
218
|
let last = -1;
|
|
218
219
|
while (true) {
|
|
219
220
|
const workers = deps.snapshot(project).liveWorkers;
|
|
220
221
|
if (workers === 0) return;
|
|
222
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`drain timed out with ${workers} live worker(s) still running — nothing was restarted; dispatch remains paused (omp-conductor resume to lift it, or re-run restart to keep waiting)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
221
227
|
if (workers !== last) deps.log(`waiting for ${workers} live worker(s) to finish`);
|
|
222
228
|
last = workers;
|
|
223
229
|
await deps.sleep(DRAIN_POLL_MS);
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Pause, drain, restart, restore — the trusted restart transaction, minus the
|
|
235
|
+
* install/verify steps of {@link upgradeConductor}.
|
|
236
|
+
*
|
|
237
|
+
* Mirrors the upgrade's fail-closed posture: on ANY throw after the pause the
|
|
238
|
+
* fleet stays paused (no resume in a catch) and the error rethrows, so a
|
|
239
|
+
* systemd-owned restart that fails or a drain that outlives `timeoutMs` cannot
|
|
240
|
+
* silently resume dispatch over a wedged daemon. The caller surfaces the message
|
|
241
|
+
* and exits nonzero.
|
|
242
|
+
*/
|
|
243
|
+
export async function drainAndRestart(
|
|
244
|
+
deps: UpgradeDeps,
|
|
245
|
+
o: { project?: string; timeoutMs: number },
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const initial = deps.layers(o.project);
|
|
248
|
+
if (!initial.paused) deps.setPaused(true);
|
|
249
|
+
await waitForDrain(deps, o.project, Date.now() + o.timeoutMs);
|
|
250
|
+
await deps.restartDaemon();
|
|
251
|
+
if (!initial.paused) deps.setPaused(false);
|
|
252
|
+
}
|
|
253
|
+
|
|
227
254
|
function recoveryProblem(
|
|
228
255
|
layers: FleetLayers,
|
|
229
256
|
initial: FleetLayers,
|
|
@@ -301,6 +328,7 @@ async function rollbackUpgrade(
|
|
|
301
328
|
herdrReloadStarted: boolean,
|
|
302
329
|
daemonReloadStarted: boolean,
|
|
303
330
|
configBefore?: string,
|
|
331
|
+
preUpgradeBackup?: string,
|
|
304
332
|
): Promise<void> {
|
|
305
333
|
const failures: string[] = [];
|
|
306
334
|
|
|
@@ -318,12 +346,23 @@ async function rollbackUpgrade(
|
|
|
318
346
|
const path = configPath();
|
|
319
347
|
if (readFileSync(path, "utf8") !== configBefore) {
|
|
320
348
|
deps.log("rollback: conductor config.json");
|
|
349
|
+
// Name the durable snapshot: the in-memory restore only runs while
|
|
350
|
+
// this process is alive, and an upgrade that died mid-flight leaves
|
|
351
|
+
// the operator needing exactly these bytes from disk.
|
|
352
|
+
deps.log(
|
|
353
|
+
preUpgradeBackup === undefined
|
|
354
|
+
? "rollback: config.json changed during the upgrade; no durable snapshot was persisted"
|
|
355
|
+
: `rollback: config.json changed during the upgrade; pre-upgrade snapshot at ${preUpgradeBackup}`,
|
|
356
|
+
);
|
|
321
357
|
// Exact bytes. `writeConfigFile(JSON.parse(...))` returns canonically
|
|
322
358
|
// formatted output, which restores the keys but not the file.
|
|
323
359
|
writeConfigRaw(configBefore);
|
|
324
360
|
}
|
|
325
361
|
} catch (err) {
|
|
326
|
-
failures.push(
|
|
362
|
+
failures.push(
|
|
363
|
+
`could not restore config.json: ${err instanceof Error ? err.message : String(err)}` +
|
|
364
|
+
(preUpgradeBackup === undefined ? "" : `; pre-upgrade snapshot at ${preUpgradeBackup}`),
|
|
365
|
+
);
|
|
327
366
|
}
|
|
328
367
|
}
|
|
329
368
|
const restore = async (label: string, command: string, args: readonly string[]): Promise<void> => {
|
|
@@ -470,13 +509,23 @@ export async function upgradeConductor(
|
|
|
470
509
|
// Snapshotted before anything is installed, because the new daemon starts
|
|
471
510
|
// during this transaction and may migrate the file — see the restore in
|
|
472
511
|
// `rollbackUpgrade`. Read as bytes, not through the loader: the point is to
|
|
473
|
-
// put back exactly what was there, dialect and all.
|
|
512
|
+
// put back exactly what was there, dialect and all. The same bytes are also
|
|
513
|
+
// persisted under state, so a process death between here and the rollback
|
|
514
|
+
// cannot take the only pre-upgrade copy with it.
|
|
474
515
|
let configBefore: string | undefined;
|
|
516
|
+
let preUpgradeBackup: string | undefined;
|
|
475
517
|
try {
|
|
476
518
|
configBefore = readFileSync(configPath(), "utf8");
|
|
519
|
+
preUpgradeBackup = copyToUniqueBackup(
|
|
520
|
+
configPath(),
|
|
521
|
+
configBackupDir(),
|
|
522
|
+
`config.json.pre-upgrade-${backupTimestamp()}`,
|
|
523
|
+
);
|
|
524
|
+
deps.log(`config backed up to ${preUpgradeBackup}`);
|
|
477
525
|
} catch {
|
|
478
526
|
// No readable config is not a reason to refuse an upgrade; there is simply
|
|
479
|
-
// nothing to put back.
|
|
527
|
+
// nothing to put back. A snapshot that failed to persist is not fatal
|
|
528
|
+
// either — the in-memory restore below still covers a clean rollback.
|
|
480
529
|
}
|
|
481
530
|
|
|
482
531
|
if (brief.kind === "missing") throw new Error("no ORCHESTRATOR.md exists for the configured project");
|
|
@@ -582,6 +631,7 @@ export async function upgradeConductor(
|
|
|
582
631
|
herdrReloadStarted,
|
|
583
632
|
daemonReloadStarted,
|
|
584
633
|
configBefore,
|
|
634
|
+
preUpgradeBackup,
|
|
585
635
|
);
|
|
586
636
|
} catch (rollbackErr) {
|
|
587
637
|
const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
package/src/worktree.ts
CHANGED
|
@@ -263,14 +263,22 @@ function refreshManagedExclude(worktree: string): void {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Serializes concurrent {@link ensureMirror} calls per mirror path so two
|
|
268
|
+
* dispatches cannot collide on git's ref locks for the same repo (#186). A
|
|
269
|
+
* rejected (failed) call does not poison the chain: the next caller waits on a
|
|
270
|
+
* settled promise.
|
|
271
|
+
*/
|
|
272
|
+
const mirrorLocks = new Map<string, Promise<unknown>>();
|
|
273
|
+
|
|
266
274
|
/**
|
|
267
275
|
* Returns the path of the bare mirror for `repo`, cloning it on first use and
|
|
268
276
|
* refreshing it otherwise.
|
|
269
277
|
*
|
|
270
|
-
* ponytail:
|
|
271
|
-
*
|
|
272
|
-
* throw; the run is retried rather than corrupted. Upgrade path is a
|
|
273
|
-
*
|
|
278
|
+
* ponytail: the lock is per process, not a cross-process lockfile in
|
|
279
|
+
* `mirrorRoot`. Two *dispatch loops* can still collide on git's ref locks and
|
|
280
|
+
* one will throw; the run is retried rather than corrupted. Upgrade path is a
|
|
281
|
+
* lockfile keyed by repo name, for a future multi-daemon host.
|
|
274
282
|
*
|
|
275
283
|
* ponytail: if `repo.cloneUrl` embeds credentials, `git clone` persists them in
|
|
276
284
|
* the mirror's config, exactly as it would for a hand-run clone. Prefer an SSH
|
|
@@ -280,6 +288,17 @@ export async function ensureMirror(
|
|
|
280
288
|
repo: RepoTarget,
|
|
281
289
|
mirrorRoot: string,
|
|
282
290
|
): Promise<string> {
|
|
291
|
+
const key = mirrorPathFor(repo, mirrorRoot);
|
|
292
|
+
const prev = mirrorLocks.get(key) ?? Promise.resolve();
|
|
293
|
+
const next = prev.catch(() => {}).then(() => ensureMirrorUnlocked(repo, mirrorRoot));
|
|
294
|
+
mirrorLocks.set(key, next);
|
|
295
|
+
void next.catch(() => {}).finally(() => {
|
|
296
|
+
if (mirrorLocks.get(key) === next) mirrorLocks.delete(key);
|
|
297
|
+
});
|
|
298
|
+
return next;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function ensureMirrorUnlocked(repo: RepoTarget, mirrorRoot: string): Promise<string> {
|
|
283
302
|
mkdirSync(mirrorRoot, { recursive: true });
|
|
284
303
|
const mirrorPath = mirrorPathFor(repo, mirrorRoot);
|
|
285
304
|
|