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.
@@ -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,
@@ -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. Stop always wins, and is re-read here rather than remembered. Reads are
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 git(["remote", "update", "--prune"], mirrorPath);
288
+ await refreshMirror(mirrorPath);
266
289
  return mirrorPath;
267
290
  }
268
291