omp-conductor 0.6.0 → 0.7.1
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 +2 -0
- package/package.json +1 -1
- package/src/approval-surface.ts +8 -3
- package/src/board.ts +134 -7
- package/src/briefs/orchestrator.md +16 -0
- package/src/cli.ts +95 -2
- package/src/daemon.ts +117 -7
- package/src/failure-class.ts +79 -0
- package/src/orchestrator-tick.ts +35 -17
- package/src/store.ts +57 -4
- 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/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
|
|