omp-conductor 0.5.6 → 0.7.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 +22 -0
- package/package.json +1 -1
- package/src/approval-surface.ts +98 -4
- package/src/board.ts +134 -7
- package/src/briefs/orchestrator.md +30 -9
- package/src/briefs/policy.md +5 -4
- package/src/cli.ts +95 -2
- package/src/daemon.ts +117 -7
- package/src/failure-class.ts +62 -0
- package/src/fleet.ts +19 -1
- package/src/orchestrator-tick.ts +107 -19
- package/src/store.ts +14 -3
- package/src/tracker/github.ts +16 -0
- package/src/types.ts +12 -0
- package/src/unblock.ts +30 -1
- package/src/verbs/actions.ts +8 -0
- package/src/verbs/protocol.ts +29 -0
- package/src/verbs/server.ts +113 -10
- package/src/worktree.ts +24 -1
package/src/store.ts
CHANGED
|
@@ -61,7 +61,7 @@ export const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
|
|
|
61
61
|
* worktrees removed, so they must not consume slots, while their live PRs must
|
|
62
62
|
* still block duplicate attempts.
|
|
63
63
|
*/
|
|
64
|
-
const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-pending", "pushed-green"];
|
|
64
|
+
export const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-pending", "pushed-green"];
|
|
65
65
|
|
|
66
66
|
const LIVE_PLACEHOLDERS = LIVE_STATES.map(() => "?").join(", ");
|
|
67
67
|
const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
@@ -773,12 +773,19 @@ export function openStore(dbPath: string): Store {
|
|
|
773
773
|
const countFailures = db.query<{ n: number }, [string, number]>(
|
|
774
774
|
`SELECT COUNT(*) AS n FROM runs
|
|
775
775
|
WHERE project = ? AND issue = ? AND state = 'failed'
|
|
776
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure'))`,
|
|
776
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
|
|
777
777
|
);
|
|
778
778
|
const countContinuations = db.query<{ n: number }, [string, number]>(
|
|
779
779
|
`SELECT COUNT(*) AS n FROM runs
|
|
780
780
|
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
|
|
781
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure'))`,
|
|
781
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
|
|
782
|
+
);
|
|
783
|
+
// How many times one issue reached a given class. Recovery uses it to bound a
|
|
784
|
+
// retry loop whose cause is persistent (e.g. a mirror that will not refresh):
|
|
785
|
+
// after `DISPATCH_INFRA_MAX_STRIKES` the daemon escalates instead of looping.
|
|
786
|
+
const countByClass = db.query<{ n: number }, [string, number, FailureClass]>(
|
|
787
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
788
|
+
WHERE project = ? AND issue = ? AND failureClass = ?`,
|
|
782
789
|
);
|
|
783
790
|
// Newest first, and bounded: every row this returns costs `gh` calls to gather
|
|
784
791
|
// facts for, so a fleet with a long unclassified history classifies over
|
|
@@ -1142,6 +1149,10 @@ export function openStore(dbPath: string): Store {
|
|
|
1142
1149
|
return countContinuations.get(project, issue)?.n ?? 0;
|
|
1143
1150
|
},
|
|
1144
1151
|
|
|
1152
|
+
classCountFor(project: string, issue: number, cls: FailureClass): number {
|
|
1153
|
+
return countByClass.get(project, issue, cls)?.n ?? 0;
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1145
1156
|
latestRun(project: string, issue: number): RunRecord | undefined {
|
|
1146
1157
|
const row = selectLatestRun.get(project, issue);
|
|
1147
1158
|
return row ? toRecord(row) : undefined;
|
package/src/tracker/github.ts
CHANGED
|
@@ -695,6 +695,22 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
695
695
|
}
|
|
696
696
|
},
|
|
697
697
|
|
|
698
|
+
async checkLog(detailsUrl: string): Promise<string | undefined> {
|
|
699
|
+
const args = runLogArgs(detailsUrl);
|
|
700
|
+
if (args === undefined) return undefined;
|
|
701
|
+
try {
|
|
702
|
+
const raw = await runGh(args);
|
|
703
|
+
const lines = raw.replaceAll(/\u001b\[[0-9;]*m/g, "").split("\n");
|
|
704
|
+
return lines.slice(-400).join("\n");
|
|
705
|
+
} catch {
|
|
706
|
+
// Best-effort, never throws: a revoked token, a deleted run or a flaky
|
|
707
|
+
// network must not error the classifier out of gathering facts. The
|
|
708
|
+
// caller treats `undefined` as "could not read the log" and classifies
|
|
709
|
+
// conservatively (#177).
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
|
|
698
714
|
async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
|
|
699
715
|
if (!PR_URL.test(prUrl)) return "unknown";
|
|
700
716
|
try {
|
package/src/types.ts
CHANGED
|
@@ -766,6 +766,12 @@ export interface Tracker {
|
|
|
766
766
|
* port: nothing downstream may read it as "no checks failed".
|
|
767
767
|
*/
|
|
768
768
|
checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]>;
|
|
769
|
+
/** The tail of a failed workflow run's log, ANSI stripped, or `undefined`
|
|
770
|
+
* when it cannot be fetched. Best-effort, never throws: the classifier uses
|
|
771
|
+
* it to tell an infrastructure outage from a deterministic test failure
|
|
772
|
+
* (#177), and "could not read the log" must fall through to the existing
|
|
773
|
+
* conservative classification rather than error out of fact-gathering. */
|
|
774
|
+
checkLog(detailsUrl: string): Promise<string | undefined>;
|
|
769
775
|
/** Whether the PR can merge into its base. `unknown` on any doubt, so a
|
|
770
776
|
* mergeability nobody could read never becomes a conflict recovery. */
|
|
771
777
|
mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown">;
|
|
@@ -803,6 +809,7 @@ export const FAILURE_CLASSES = [
|
|
|
803
809
|
"admin-kill",
|
|
804
810
|
"ci-infra",
|
|
805
811
|
"ci-deterministic",
|
|
812
|
+
"dispatch-infra",
|
|
806
813
|
"merge-conflict",
|
|
807
814
|
"question",
|
|
808
815
|
"orphan-clean",
|
|
@@ -1155,6 +1162,10 @@ export interface Store {
|
|
|
1155
1162
|
failuresFor(project: string, issue: number): number;
|
|
1156
1163
|
/** Operational stops that require a bounded continuation resume. */
|
|
1157
1164
|
continuationsFor(project: string, issue: number): number;
|
|
1165
|
+
/** How many times one issue has reached a given failure class. A recovery
|
|
1166
|
+
* that keeps landing on the same class (e.g. a persistently broken mirror)
|
|
1167
|
+
* uses this to escalate instead of retrying forever. */
|
|
1168
|
+
classCountFor(project: string, issue: number, cls: FailureClass): number;
|
|
1158
1169
|
/** Newest attempt for one issue, whatever state it reached. `omp-conductor
|
|
1159
1170
|
* tail` resolves an issue number to a transcript through this; the number is
|
|
1160
1171
|
* what an operator has, the run id is not. */
|
|
@@ -1321,6 +1332,7 @@ export const VERB_NAMES = [
|
|
|
1321
1332
|
"conductor_push",
|
|
1322
1333
|
"conductor_pr_create",
|
|
1323
1334
|
"conductor_pr_update_branch",
|
|
1335
|
+
"conductor_pr_update",
|
|
1324
1336
|
"conductor_pr_merge",
|
|
1325
1337
|
"conductor_label",
|
|
1326
1338
|
"conductor_release",
|
package/src/unblock.ts
CHANGED
|
@@ -40,6 +40,10 @@ export interface UnblockOutcome {
|
|
|
40
40
|
continuationsUsed: number;
|
|
41
41
|
/** Newest attempt, when the store has one for this issue at all. */
|
|
42
42
|
latest?: RunRecord;
|
|
43
|
+
/** Whether any run for the issue is still in an active state — the answer
|
|
44
|
+
* to "will the dispatcher hold this issue as issue-active?" that #178
|
|
45
|
+
* found this verb guessing at. */
|
|
46
|
+
active: boolean;
|
|
43
47
|
/** Set when nothing was cleared because the newest attempt's work exists
|
|
44
48
|
* only in its worktree. Carries the salvage failure verbatim. */
|
|
45
49
|
refused?: string;
|
|
@@ -92,10 +96,12 @@ export async function unblockIssue(
|
|
|
92
96
|
// for clearing in-progress, so the row that carries it decides the set.
|
|
93
97
|
const latest = store.latestRun(project.name, issue);
|
|
94
98
|
const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
|
|
99
|
+
const active = store.activeRuns(project.name).some((r) => r.issue === issue);
|
|
95
100
|
const counts = {
|
|
96
101
|
attemptsUsed: store.attemptsFor(project.name, issue),
|
|
97
102
|
failuresUsed: store.failuresFor(project.name, issue),
|
|
98
103
|
continuationsUsed: store.continuationsFor(project.name, issue),
|
|
104
|
+
active,
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
// The one case where this verb refuses. Clearing the labels here re-queues an
|
|
@@ -205,8 +211,31 @@ export function formatUnblock(
|
|
|
205
211
|
`run row to prove the worker is gone, "${project.stateLabels.inProgress}" was left in place, and ` +
|
|
206
212
|
"on its own it keeps the issue ineligible",
|
|
207
213
|
);
|
|
214
|
+
} else if (latest.state === "pushed-green" || latest.state === "pushed-pending") {
|
|
215
|
+
// #178: an unblocked issue whose newest run pushed a green PR is not "free
|
|
216
|
+
// for a fresh attempt" — the dispatcher continues the pushed run's branch,
|
|
217
|
+
// and only once the PR resolves does the issue settle (#175). Say that
|
|
218
|
+
// instead of promising a re-claim the next tick withholds.
|
|
219
|
+
lines.push(
|
|
220
|
+
latest.prUrl === undefined
|
|
221
|
+
? ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
|
|
222
|
+
`(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
|
|
223
|
+
: ` next tick eligible as a continuation of ${latest.prUrl} — the pushed run stays active until ` +
|
|
224
|
+
"that PR resolves; dispatch continues on its branch",
|
|
225
|
+
);
|
|
226
|
+
} else if (o.active) {
|
|
227
|
+
// A run other than the newest is still active (e.g. a live sibling under a
|
|
228
|
+
// terminal newest row), so the dispatcher will hold the issue as
|
|
229
|
+
// issue-active whatever the labels now say. #178's misleading case.
|
|
230
|
+
lines.push(
|
|
231
|
+
` in flight a run for this issue is still active, so the dispatcher holds the issue until it settles — ` +
|
|
232
|
+
`nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
|
|
233
|
+
);
|
|
208
234
|
} else {
|
|
209
|
-
lines.push(
|
|
235
|
+
lines.push(
|
|
236
|
+
` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
|
|
237
|
+
"(the dispatcher still applies its open-PR check at claim time)",
|
|
238
|
+
);
|
|
210
239
|
}
|
|
211
240
|
|
|
212
241
|
return lines.join("\n");
|
package/src/verbs/actions.ts
CHANGED
|
@@ -97,6 +97,14 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
|
|
|
97
97
|
|
|
98
98
|
updatePrBranch: (prUrl) => gh(["pr", "update-branch", prUrl]),
|
|
99
99
|
|
|
100
|
+
updatePr: async (prUrl, fields) => {
|
|
101
|
+
const argv = ["pr", "edit", prUrl];
|
|
102
|
+
if (fields.title !== undefined) argv.push("--title", fields.title);
|
|
103
|
+
if (fields.body !== undefined) argv.push("--body", fields.body);
|
|
104
|
+
const outcome = await gh(argv);
|
|
105
|
+
return outcome.ok ? { ok: true } : { ok: false, stderr: outcome.stderr };
|
|
106
|
+
},
|
|
107
|
+
|
|
100
108
|
// `--match-head-commit` is the server-side half of the exact-head rule: the
|
|
101
109
|
// daemon already re-read the head and refused a stale one, and this makes
|
|
102
110
|
// GitHub refuse too if the branch moved in the milliseconds between. Belt
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -145,6 +145,35 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
145
145
|
},
|
|
146
146
|
roleRefusalText: (role) => `conductor_pr_update_branch is not open to a ${role} session.`,
|
|
147
147
|
},
|
|
148
|
+
conductor_pr_update: {
|
|
149
|
+
name: "conductor_pr_update",
|
|
150
|
+
mutating: true,
|
|
151
|
+
allowedRoles: ["worker", "orchestrator"],
|
|
152
|
+
description:
|
|
153
|
+
"Update this run's pull request title and/or body. Never touches base, head or state — " +
|
|
154
|
+
"a field you may not change is a field this verb refuses rather than renames. A worker " +
|
|
155
|
+
"may only name its own run's PR.",
|
|
156
|
+
args: {
|
|
157
|
+
prUrl: {
|
|
158
|
+
type: "string",
|
|
159
|
+
required: false,
|
|
160
|
+
description:
|
|
161
|
+
"Full pull request URL. Required on an orchestrator channel; a run channel infers its own PR.",
|
|
162
|
+
},
|
|
163
|
+
title: {
|
|
164
|
+
type: "string",
|
|
165
|
+
required: false,
|
|
166
|
+
description: "New pull request title. At least one of title/body must be a non-empty string.",
|
|
167
|
+
},
|
|
168
|
+
body: {
|
|
169
|
+
type: "string",
|
|
170
|
+
required: false,
|
|
171
|
+
description: "New pull request body. At least one of title/body must be a non-empty string.",
|
|
172
|
+
},
|
|
173
|
+
rationale: RATIONALE_ARG,
|
|
174
|
+
},
|
|
175
|
+
roleRefusalText: (role) => `conductor_pr_update is not open to a ${role} session.`,
|
|
176
|
+
},
|
|
148
177
|
conductor_pr_merge: {
|
|
149
178
|
name: "conductor_pr_merge",
|
|
150
179
|
mutating: true,
|
package/src/verbs/server.ts
CHANGED
|
@@ -145,6 +145,10 @@ export interface VerbActions {
|
|
|
145
145
|
opts: { title: string; body: string; base: string },
|
|
146
146
|
): Promise<{ ok: true; url: string } | { ok: false; stderr: string }>;
|
|
147
147
|
updatePrBranch(prUrl: string): Promise<ActionOutcome>;
|
|
148
|
+
updatePr(
|
|
149
|
+
prUrl: string,
|
|
150
|
+
fields: { title?: string; body?: string },
|
|
151
|
+
): Promise<{ ok: true } | { ok: false; stderr: string }>;
|
|
148
152
|
mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
|
|
149
153
|
setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
|
|
150
154
|
release(execution: ReleaseExecution): Promise<ActionOutcome>;
|
|
@@ -167,6 +171,14 @@ export interface VerbDeps {
|
|
|
167
171
|
* sentinel alongside disarming ticks, so one read covers both.
|
|
168
172
|
*/
|
|
169
173
|
fleetStop: () => string | undefined;
|
|
174
|
+
/**
|
|
175
|
+
* The epoch-ms timestamp the current pause began, or `undefined` when not
|
|
176
|
+
* paused (or when the sentinel does not parse). Lets the fleet-stop gate tell
|
|
177
|
+
* a mutating call from a run admitted *before* the pause — that run may
|
|
178
|
+
* finish — apart from one admitted after, which must be refused. Parallels
|
|
179
|
+
* {@link VerbDeps.fleetStop} by living behind the same per-call read.
|
|
180
|
+
*/
|
|
181
|
+
pausedAt: () => number | undefined;
|
|
170
182
|
log: (message: string) => void;
|
|
171
183
|
now: () => number;
|
|
172
184
|
}
|
|
@@ -387,17 +399,11 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
387
399
|
);
|
|
388
400
|
}
|
|
389
401
|
|
|
390
|
-
// 3.
|
|
391
|
-
// exempt: a run finishing its report during a `hold` still has to be able
|
|
392
|
-
// to say what it saw, and refusing that would only make it guess.
|
|
393
|
-
const stopped = spec.mutating ? deps.fleetStop() : undefined;
|
|
394
|
-
if (stopped !== undefined) {
|
|
395
|
-
return refuse("fleet-paused", `refused: ${stopped}. Mutating verbs are refused while the fleet is stopped.`);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// 4. The run's own row, re-read. A worker whose run was killed, orphaned or
|
|
402
|
+
// 3. The run's own row, re-read. A worker whose run was killed, orphaned or
|
|
399
403
|
// settled between its last turn and this call is no longer holding
|
|
400
|
-
// anything, and a push from it would land work nothing is tracking.
|
|
404
|
+
// anything, and a push from it would land work nothing is tracking. Read
|
|
405
|
+
// *before* the stop gate below, because the stop gate needs this row to
|
|
406
|
+
// tell an in-flight run (which may finish) from a post-pause call.
|
|
401
407
|
let run: RunRecord | undefined;
|
|
402
408
|
if (channel.kind === "run") {
|
|
403
409
|
run = deps.store.getRun(channel.runId);
|
|
@@ -409,6 +415,22 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
409
415
|
}
|
|
410
416
|
}
|
|
411
417
|
|
|
418
|
+
// 4. Stop always wins, and is re-read here rather than remembered. Reads are
|
|
419
|
+
// exempt: a run finishing its report during a `hold` still has to be able
|
|
420
|
+
// to say what it saw, and refusing that would only make it guess. A run
|
|
421
|
+
// admitted *before* the pause may finish its in-flight mutations — pause
|
|
422
|
+
// stops new claims, not the work already running (#174) — so only a call
|
|
423
|
+
// whose run started at or after the pause (or an orchestrator's call, with
|
|
424
|
+
// no run row at all) is refused.
|
|
425
|
+
const stopped = spec.mutating ? deps.fleetStop() : undefined;
|
|
426
|
+
if (stopped !== undefined) {
|
|
427
|
+
const at = deps.pausedAt();
|
|
428
|
+
const admittedBeforePause = run !== undefined && at !== undefined && run.startedAt < at;
|
|
429
|
+
if (!admittedBeforePause) {
|
|
430
|
+
return refuse("fleet-paused", `refused: ${stopped}. The pause stops new claims and new mutations; a run admitted before the pause may finish.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
412
434
|
switch (verb) {
|
|
413
435
|
case "conductor_push":
|
|
414
436
|
return pushVerb(deps, channel, args, refuse, allow);
|
|
@@ -416,6 +438,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
416
438
|
return prCreateVerb(deps, project, channel, args, refuse, allow);
|
|
417
439
|
case "conductor_pr_update_branch":
|
|
418
440
|
return prUpdateBranchVerb(deps, project, channel, args, refuse, allow);
|
|
441
|
+
case "conductor_pr_update":
|
|
442
|
+
return prUpdateVerb(deps, project, channel, args, refuse, allow);
|
|
419
443
|
case "conductor_pr_merge":
|
|
420
444
|
return prMergeVerb(deps, project, channel, args, refuse, allow);
|
|
421
445
|
case "conductor_label":
|
|
@@ -530,6 +554,18 @@ async function prCreateVerb(
|
|
|
530
554
|
);
|
|
531
555
|
}
|
|
532
556
|
if (closer !== undefined) {
|
|
557
|
+
// Adoption (#176): a continuation run is never bound to its issue's existing
|
|
558
|
+
// PR — the old row owns the prUrl, and this new row's prUrl stayed NULL — so
|
|
559
|
+
// when the open PR was opened from *this* run's branch, it is the same work.
|
|
560
|
+
// Bind it to this run instead of refusing: pr_status then answers from the
|
|
561
|
+
// live PR, and a merge can act on it. Branch mismatch (or an empty headRefName,
|
|
562
|
+
// which is never identity) keeps the recorded refusal.
|
|
563
|
+
if (closer.headRefName !== "" && closer.headRefName === run.branch) {
|
|
564
|
+
deps.store.updateRun(run.runId, { prUrl: closer.url });
|
|
565
|
+
return allow(
|
|
566
|
+
`adopted ${closer.url} — already open for ${run.branch} and closes #${run.issue}; title/body arguments were not applied`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
533
569
|
return refuse("open-pr-exists", `refused: ${closer.url} is already open and already closes #${run.issue}.`);
|
|
534
570
|
}
|
|
535
571
|
|
|
@@ -925,6 +961,73 @@ async function prStatusVerb(
|
|
|
925
961
|
return allow(`${prUrl} at ${headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
|
|
926
962
|
}
|
|
927
963
|
|
|
964
|
+
/**
|
|
965
|
+
* Update one pull request's title and/or body — nothing else.
|
|
966
|
+
*
|
|
967
|
+
* Same resolution as {@link prStatusVerb}: a worker edits its own run's PR (and
|
|
968
|
+
* only that one), an orchestrator names one this project's runs opened.
|
|
969
|
+
* `conductor_pr_update` exists so the floor's own edits go through the same
|
|
970
|
+
* checks and the same ledger as every other mutation, instead of a raw
|
|
971
|
+
* `gh pr edit` from a session that already disclosed it would do exactly that
|
|
972
|
+
* (#176).
|
|
973
|
+
*/
|
|
974
|
+
async function prUpdateVerb(
|
|
975
|
+
deps: VerbDeps,
|
|
976
|
+
project: ProjectConfig,
|
|
977
|
+
channel: VerbChannel,
|
|
978
|
+
args: Record<string, unknown>,
|
|
979
|
+
refuse: Refuse,
|
|
980
|
+
allow: Allow,
|
|
981
|
+
): Promise<Verdict> {
|
|
982
|
+
const asked = args["prUrl"];
|
|
983
|
+
let prUrl: string;
|
|
984
|
+
let issue: number | undefined;
|
|
985
|
+
|
|
986
|
+
if (channel.kind === "run") {
|
|
987
|
+
const own = deps.store.getRun(channel.runId);
|
|
988
|
+
if (own?.prUrl === undefined) {
|
|
989
|
+
return refuse("pr-missing", "refused: this run has no pull request yet. Open one with conductor_pr_create.");
|
|
990
|
+
}
|
|
991
|
+
if (typeof asked === "string" && asked !== own.prUrl) {
|
|
992
|
+
return refuse(
|
|
993
|
+
"pr-not-this-run",
|
|
994
|
+
`refused: this socket belongs to the run for #${channel.issue}, whose pull request is ${own.prUrl}. ` +
|
|
995
|
+
"A worker edits only its own run's PR.",
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
prUrl = own.prUrl;
|
|
999
|
+
issue = channel.issue;
|
|
1000
|
+
} else {
|
|
1001
|
+
if (typeof asked !== "string") {
|
|
1002
|
+
return refuse("malformed-argument", "refused: conductor_pr_update needs prUrl when it has no run to infer one from.");
|
|
1003
|
+
}
|
|
1004
|
+
const target = runForPr(deps, project.name, asked);
|
|
1005
|
+
if (target === undefined) {
|
|
1006
|
+
return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
|
|
1007
|
+
}
|
|
1008
|
+
prUrl = asked;
|
|
1009
|
+
issue = target.issue;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const title = args["title"];
|
|
1013
|
+
const body = args["body"];
|
|
1014
|
+
if (!(typeof title === "string" && title !== "") && !(typeof body === "string" && body !== "")) {
|
|
1015
|
+
return refuse(
|
|
1016
|
+
"malformed-argument",
|
|
1017
|
+
"refused: conductor_pr_update changes nothing unless at least one of title or body is a non-empty string.",
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const outcome = await deps.actions.updatePr(prUrl, {
|
|
1022
|
+
...(typeof title === "string" && title !== "" ? { title } : {}),
|
|
1023
|
+
...(typeof body === "string" && body !== "" ? { body } : {}),
|
|
1024
|
+
});
|
|
1025
|
+
if (!outcome.ok) {
|
|
1026
|
+
return refuse("action-failed", `refused: gh could not update the pull request:\n${outcome.stderr}`, issue);
|
|
1027
|
+
}
|
|
1028
|
+
return allow(`updated ${prUrl}.`, undefined, issue);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
928
1031
|
// ------------------------------------------------------------------ the listener
|
|
929
1032
|
|
|
930
1033
|
export interface VerbListener {
|
package/src/worktree.ts
CHANGED
|
@@ -108,6 +108,29 @@ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
|
|
|
108
108
|
return code === 0;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Fences the block below so it can be found, replaced, and never duplicated. */
|
|
112
|
+
const REF_LOCK_SIGNATURES = /cannot lock ref|unable to update local ref/i;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Refreshes a bare mirror's refs from its remote. Two dispatch loops can race
|
|
116
|
+
* on git's *packed-refs* / ref locks for the same repo and one lands a transient
|
|
117
|
+
* `cannot lock ref` / `unable to update local ref` — that is not corruption, but
|
|
118
|
+
* an {@link ensureMirror} that throws on it aborts the whole dispatch at turn 0
|
|
119
|
+
* and chars a failed attempt (#168). Retry once after a short backoff; a second
|
|
120
|
+
* failure (or any non-ref-lock failure) propagates unchanged, so a genuinely
|
|
121
|
+
* broken mirror still surfaces loudly.
|
|
122
|
+
*/
|
|
123
|
+
async function refreshMirror(mirrorPath: string): Promise<string> {
|
|
124
|
+
try {
|
|
125
|
+
return await git(["remote", "update", "--prune"], mirrorPath);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
128
|
+
if (!REF_LOCK_SIGNATURES.test(message)) throw err;
|
|
129
|
+
await new Promise<void>((r) => setTimeout(r, 500));
|
|
130
|
+
return git(["remote", "update", "--prune"], mirrorPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
/** Fences the block below so it can be found, replaced, and never duplicated. */
|
|
112
135
|
const EXCLUDE_BEGIN = "# >>> omp-conductor (managed; edit outside this block)";
|
|
113
136
|
const EXCLUDE_END = "# <<< omp-conductor";
|
|
@@ -262,7 +285,7 @@ export async function ensureMirror(
|
|
|
262
285
|
|
|
263
286
|
if (existsSync(mirrorPath)) {
|
|
264
287
|
await configureMirror(mirrorPath);
|
|
265
|
-
await
|
|
288
|
+
await refreshMirror(mirrorPath);
|
|
266
289
|
return mirrorPath;
|
|
267
290
|
}
|
|
268
291
|
|