omp-conductor 0.17.0 → 0.18.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/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/verbs/actions.ts
CHANGED
|
@@ -20,6 +20,13 @@ import { stateDir } from "../config.ts";
|
|
|
20
20
|
import { ensureMirror } from "../worktree.ts";
|
|
21
21
|
import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
|
|
22
22
|
import { classifyUpgrade } from "../upgrade-verify.ts";
|
|
23
|
+
import {
|
|
24
|
+
GraphqlBreaker,
|
|
25
|
+
graphqlBreakerRefusal,
|
|
26
|
+
isGraphqlSurface,
|
|
27
|
+
isTransientServer5xx,
|
|
28
|
+
prUrlParts,
|
|
29
|
+
} from "../tracker/github.ts";
|
|
23
30
|
import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
|
|
24
31
|
import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
|
|
25
32
|
|
|
@@ -204,15 +211,32 @@ export function githubVerbActions(
|
|
|
204
211
|
project: ProjectConfig,
|
|
205
212
|
run: CommandRunner = spawnCommand,
|
|
206
213
|
prepareMirror: MirrorPreparer = ensureMirror,
|
|
214
|
+
graphqlBreaker?: GraphqlBreaker,
|
|
207
215
|
): VerbActions {
|
|
208
216
|
const env = (): Record<string, string> => credentialedEnv();
|
|
209
217
|
|
|
210
218
|
const runGh = async (argv: string[], cwd?: string): Promise<{ argv: string[]; result: CommandRun }> => {
|
|
211
219
|
const full = ["gh", ...argv];
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
// The mutation surface rides the same GraphQL endpoint the tracker's
|
|
221
|
+
// parent/closer checks ride in the pinned gh (2.86): `pr merge`,
|
|
222
|
+
// `pr update-branch`, `pr edit`, `pr create`. When a transient 5xx has
|
|
223
|
+
// already proven that surface unavailable — observed by admission or by a
|
|
224
|
+
// sibling mutation this pass — a mutation must fast-fail WITHOUT spawning
|
|
225
|
+
// `gh`, exactly like the tracker's candidate checks (#642 comment 4):
|
|
226
|
+
// attempting a mutation into a known-broken surface is how one provider
|
|
227
|
+
// outage defeats queue locking. REST-backed commands (`gh api repos/...`,
|
|
228
|
+
// `gh release create`) keep running: the outage is GraphQL-only.
|
|
229
|
+
if (graphqlBreaker !== undefined && isGraphqlSurface(argv) && graphqlBreaker.refused()) {
|
|
230
|
+
return {
|
|
231
|
+
argv: full,
|
|
232
|
+
result: { ok: false, stdout: "", stderr: graphqlBreakerRefusal(graphqlBreaker.until) },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const result = await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) });
|
|
236
|
+
if (!result.ok && graphqlBreaker !== undefined && isGraphqlSurface(argv) && isTransientServer5xx(result.stderr)) {
|
|
237
|
+
graphqlBreaker.open();
|
|
238
|
+
}
|
|
239
|
+
return { argv: full, result };
|
|
216
240
|
};
|
|
217
241
|
|
|
218
242
|
const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
|
|
@@ -573,7 +597,44 @@ export function githubVerbActions(
|
|
|
573
597
|
|
|
574
598
|
createPr: (target, opts) => openRunPr(project, target, opts),
|
|
575
599
|
|
|
576
|
-
updatePrBranch: (prUrl) =>
|
|
600
|
+
updatePrBranch: async (prUrl) => {
|
|
601
|
+
// The branch refresh rides the REST update-branch endpoint, not `gh pr
|
|
602
|
+
// update-branch` (#655): `gh pr update-branch` is a GraphQL mutation, so
|
|
603
|
+
// a selective GraphQL outage made an otherwise mergeable PR unrefreshable
|
|
604
|
+
// even while REST answered. Both calls here are core REST, so they keep
|
|
605
|
+
// running while the GraphQL breaker is open.
|
|
606
|
+
const parts = prUrlParts(prUrl);
|
|
607
|
+
if (parts === undefined) {
|
|
608
|
+
return { ok: false, stderr: `could not parse ${prUrl} as a pull request URL` };
|
|
609
|
+
}
|
|
610
|
+
const pulls = `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`;
|
|
611
|
+
// The stale-head guard needs the exact head the update is bound to. It is
|
|
612
|
+
// the last observed head of the branch refresh, so a concurrent push is
|
|
613
|
+
// refused (422) rather than updating an unreviewed head.
|
|
614
|
+
const head = await runGh(["api", pulls, "--jq", ".head.sha"]);
|
|
615
|
+
if (!head.result.ok) return failed(head.result, head.argv);
|
|
616
|
+
const headSha = commitFrom(head.result.stdout);
|
|
617
|
+
if (headSha === undefined) {
|
|
618
|
+
return { ok: false, stderr: `${prUrl} returned no full head commit SHA` };
|
|
619
|
+
}
|
|
620
|
+
const updated = await gh([
|
|
621
|
+
"api",
|
|
622
|
+
"--method",
|
|
623
|
+
"PUT",
|
|
624
|
+
`${pulls}/update-branch`,
|
|
625
|
+
"-f",
|
|
626
|
+
`expected_head_sha=${headSha}`,
|
|
627
|
+
]);
|
|
628
|
+
if (!updated.ok) return updated;
|
|
629
|
+
// The endpoint answers 202 Accepted and refreshes the branch
|
|
630
|
+
// asynchronously, so the head observed for `expected_head_sha` is the
|
|
631
|
+
// pre-update head. Producing it as `sha` would report a merge at a head
|
|
632
|
+
// nobody observed; the caller re-reads PR and check state instead.
|
|
633
|
+
return {
|
|
634
|
+
ok: true,
|
|
635
|
+
detail: `requested a base-branch update of ${prUrl} from its observed head ${headSha}; re-read PR and check state before merging`,
|
|
636
|
+
};
|
|
637
|
+
},
|
|
577
638
|
|
|
578
639
|
updatePr: async (prUrl, fields) => {
|
|
579
640
|
const argv = ["pr", "edit", prUrl];
|
|
@@ -589,19 +650,6 @@ export function githubVerbActions(
|
|
|
589
650
|
// and braces on the one operation that cannot be undone.
|
|
590
651
|
mergePr: (prUrl, headSha) => gh(["pr", "merge", prUrl, "--squash", "--match-head-commit", headSha]),
|
|
591
652
|
|
|
592
|
-
setLabel: async (issue, label, action) => {
|
|
593
|
-
const slug = project.tracker.repo;
|
|
594
|
-
return gh([
|
|
595
|
-
"issue",
|
|
596
|
-
"edit",
|
|
597
|
-
String(issue),
|
|
598
|
-
"--repo",
|
|
599
|
-
slug,
|
|
600
|
-
action === "add" ? "--add-label" : "--remove-label",
|
|
601
|
-
label,
|
|
602
|
-
]);
|
|
603
|
-
},
|
|
604
|
-
|
|
605
653
|
release: async (execution: ReleaseExecution): Promise<ActionOutcome> => {
|
|
606
654
|
const slug = repoSlugFor(execution.repo);
|
|
607
655
|
const tag = execution.tag;
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
RELEASE_REASONS,
|
|
27
27
|
RELEASE_SHAPES,
|
|
28
28
|
RESERVED_VERB_FIELDS,
|
|
29
|
+
REVIEW_REASONS,
|
|
29
30
|
VERB_NAMES,
|
|
30
31
|
} from "../types.ts";
|
|
31
32
|
import type { ReservedVerbField, SessionRole, VerbName, VerbRefusal } from "../types.ts";
|
|
@@ -326,6 +327,50 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
326
327
|
`install authority is the orchestrator's, and this is a ${role} session. ` +
|
|
327
328
|
"A worker session holds no install grant whatever the config says.",
|
|
328
329
|
},
|
|
330
|
+
conductor_pr_review: {
|
|
331
|
+
name: "conductor_pr_review",
|
|
332
|
+
mutating: true,
|
|
333
|
+
allowedRoles: ["orchestrator"],
|
|
334
|
+
description:
|
|
335
|
+
"Return one green, run-owned pull request to its worker with blocking findings (#677). " +
|
|
336
|
+
"The daemon records the revision durably — findings, exact reviewed head, round number, " +
|
|
337
|
+
"target run and session — then resumes the SAME OMP session on the existing branch and PR: " +
|
|
338
|
+
"no close, no reopen, no issue redispatch, no fresh brief. The worker edits and pushes the " +
|
|
339
|
+
"same branch; a new green head returns to the normal orchestrator review surface. Refused " +
|
|
340
|
+
"for a stale head, a PR no run of this project owns, a run that is not settled green, or a " +
|
|
341
|
+
"revision already in flight for the same PR.",
|
|
342
|
+
args: {
|
|
343
|
+
prUrl: {
|
|
344
|
+
type: "string",
|
|
345
|
+
required: true,
|
|
346
|
+
description:
|
|
347
|
+
"Full pull request URL — one a run of this project opened and pushed green. Must be in " +
|
|
348
|
+
"a routed repository.",
|
|
349
|
+
},
|
|
350
|
+
headSha: {
|
|
351
|
+
type: "string",
|
|
352
|
+
required: true,
|
|
353
|
+
description:
|
|
354
|
+
"The exact reviewed head. The live head must match it and the checks at it must be green.",
|
|
355
|
+
},
|
|
356
|
+
findings: {
|
|
357
|
+
type: "string",
|
|
358
|
+
required: true,
|
|
359
|
+
description:
|
|
360
|
+
"Structured blocking findings. Delivered verbatim to the resumed session; the worker is " +
|
|
361
|
+
"told to address exactly these and push the same branch.",
|
|
362
|
+
},
|
|
363
|
+
reason: {
|
|
364
|
+
type: "string",
|
|
365
|
+
required: true,
|
|
366
|
+
description: `Why this revision, from the closed set: ${REVIEW_REASONS.join(", ")}.`,
|
|
367
|
+
oneOf: REVIEW_REASONS,
|
|
368
|
+
},
|
|
369
|
+
rationale: RATIONALE_ARG,
|
|
370
|
+
},
|
|
371
|
+
roleRefusalText: (role) =>
|
|
372
|
+
`review authority is the orchestrator's; this is a ${role} session, and a worker never returns its own PR for revision.`,
|
|
373
|
+
},
|
|
329
374
|
conductor_pr_status: {
|
|
330
375
|
name: "conductor_pr_status",
|
|
331
376
|
mutating: false,
|
package/src/verbs/server.ts
CHANGED
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
import { randomUUID } from "node:crypto";
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
|
|
44
|
-
import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
|
|
44
|
+
import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
|
|
45
|
+
import { effectiveLane, laneEcho } from "../admission.ts";
|
|
45
46
|
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
46
47
|
import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
47
48
|
import { releaseRefusal } from "../release-policy.ts";
|
|
48
49
|
import { LIVE_STATES } from "../store.ts";
|
|
49
50
|
import type {
|
|
51
|
+
IssueComment,
|
|
50
52
|
OpenCloser,
|
|
51
53
|
PrState,
|
|
52
54
|
PrVerification,
|
|
@@ -54,6 +56,7 @@ import type {
|
|
|
54
56
|
ReleaseRequirement,
|
|
55
57
|
ReleaseShape,
|
|
56
58
|
RepoTarget,
|
|
59
|
+
ReviewReason,
|
|
57
60
|
RunRecord,
|
|
58
61
|
Store,
|
|
59
62
|
Tracker,
|
|
@@ -167,7 +170,6 @@ export interface VerbActions {
|
|
|
167
170
|
fields: { title?: string; body?: string },
|
|
168
171
|
): Promise<{ ok: true } | { ok: false; stderr: string }>;
|
|
169
172
|
mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
|
|
170
|
-
setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
|
|
171
173
|
release(execution: ReleaseExecution): Promise<ActionOutcome>;
|
|
172
174
|
/**
|
|
173
175
|
* Request the fleet upgrade itself to `version`: refuse when npm does not
|
|
@@ -298,6 +300,12 @@ export interface ReleaseFacts {
|
|
|
298
300
|
* must wait on a process; these clear by themselves on the next sweep.
|
|
299
301
|
*/
|
|
300
302
|
unconfirmedMerges?: number;
|
|
303
|
+
/**
|
|
304
|
+
* The unsettled runs themselves, routed-repo and issue each, so the refusal
|
|
305
|
+
* names what is blocking instead of a bare count (#603). Absent when the
|
|
306
|
+
* caller could not gather them (the plain wording is used then).
|
|
307
|
+
*/
|
|
308
|
+
blockingRuns?: { repo: string; issue: number }[];
|
|
301
309
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
302
310
|
queueDepth: number | undefined;
|
|
303
311
|
/** Current live-head workflow verdict for the released routed repository. */
|
|
@@ -311,7 +319,7 @@ export function releaseRequirementRefusal(
|
|
|
311
319
|
facts: ReleaseFacts,
|
|
312
320
|
): string | undefined {
|
|
313
321
|
for (const requirement of requires) {
|
|
314
|
-
if (requirement === "runs-settled" && facts.unsettledRuns > 0) {
|
|
322
|
+
if ((requirement === "runs-settled" || requirement === "fleet-runs-settled") && facts.unsettledRuns > 0) {
|
|
315
323
|
// The same blocker reads differently depending on WHY the row is still
|
|
316
324
|
// active: a live worker is the operator's wait, an unconfirmed merge or
|
|
317
325
|
// an unrecorded sweep clears by itself on the next settle pass. The
|
|
@@ -328,9 +336,16 @@ export function releaseRequirementRefusal(
|
|
|
328
336
|
if (open > 0) parts.push(`${open} pull request(s) still open`);
|
|
329
337
|
if (unconfirmed > 0) parts.push(`${unconfirmed} run(s) whose PR merge the settle sweep has not yet recorded`);
|
|
330
338
|
if (remaining > 0) parts.push(`${remaining} run(s) with no merged PR (closed or none yet)`);
|
|
339
|
+
// Name the runs behind the count so the operator can see at a glance
|
|
340
|
+
// whether this is the release's own work or someone else's (#603).
|
|
341
|
+
const blocking =
|
|
342
|
+
facts.blockingRuns === undefined || facts.blockingRuns.length === 0
|
|
343
|
+
? ""
|
|
344
|
+
: ` — blocking: ${facts.blockingRuns.map((r) => `${r.repo} #${r.issue}`).join(", ")}`;
|
|
331
345
|
return (
|
|
332
|
-
`policy.release.requires includes
|
|
333
|
-
(parts.length === 0 ? "" : ` — ${parts.join("; ")}`)
|
|
346
|
+
`policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
|
|
347
|
+
(parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
|
|
348
|
+
blocking
|
|
334
349
|
);
|
|
335
350
|
}
|
|
336
351
|
if (requirement === "no-open-prs" && facts.openPrs > 0) {
|
|
@@ -505,6 +520,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
505
520
|
(verb === "conductor_pr_merge" ||
|
|
506
521
|
verb === "conductor_pr_update_branch" ||
|
|
507
522
|
verb === "conductor_pr_update" ||
|
|
523
|
+
verb === "conductor_pr_review" ||
|
|
508
524
|
verb === "conductor_label");
|
|
509
525
|
const stopped =
|
|
510
526
|
spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
|
|
@@ -540,6 +556,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
540
556
|
return releaseVerb(deps, project, channel, args, refuse, allow);
|
|
541
557
|
case "conductor_install":
|
|
542
558
|
return installVerb(deps, project, channel, args, refuse, allow);
|
|
559
|
+
case "conductor_pr_review":
|
|
560
|
+
return prReviewVerb(deps, project, channel, args, refuse, allow);
|
|
543
561
|
case "conductor_pr_status":
|
|
544
562
|
return prStatusVerb(deps, project, channel, args, refuse, allow);
|
|
545
563
|
}
|
|
@@ -797,7 +815,15 @@ async function prUpdateBranchVerb(
|
|
|
797
815
|
if (!outcome.ok) {
|
|
798
816
|
return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target?.issue);
|
|
799
817
|
}
|
|
800
|
-
|
|
818
|
+
// The REST endpoint answers 202 Accepted: the refresh runs asynchronously, so
|
|
819
|
+
// this call produces no head SHA and the reply/ledger must not claim one —
|
|
820
|
+
// the head the action observed is the pre-update guard head, never a result
|
|
821
|
+
// (#655 review). The caller re-reads PR and check state before merging.
|
|
822
|
+
return allow(
|
|
823
|
+
`requested a base-branch update of ${prUrl} from its observed head; PR and check state must be re-read before merging.`,
|
|
824
|
+
undefined,
|
|
825
|
+
target?.issue,
|
|
826
|
+
);
|
|
801
827
|
}
|
|
802
828
|
|
|
803
829
|
async function prMergeVerb(
|
|
@@ -1064,11 +1090,59 @@ async function labelVerb(
|
|
|
1064
1090
|
}
|
|
1065
1091
|
|
|
1066
1092
|
const action = args["action"] === "remove" ? "remove" : "add";
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1093
|
+
// The queue label is the fleet's lock, so it must not ride the GraphQL
|
|
1094
|
+
// surface a provider outage just broke: `gh issue edit` is a GraphQL
|
|
1095
|
+
// mutation in gh 2.86, and #642 comment 4 measured a label withdrawal
|
|
1096
|
+
// refused by a GraphQL 503 leaving two overlapping issues claimable. The
|
|
1097
|
+
// tracker's label methods are core REST with noop classification for the
|
|
1098
|
+
// already-holding end state — the guarded path.
|
|
1099
|
+
try {
|
|
1100
|
+
if (action === "add") {
|
|
1101
|
+
await deps.tracker.addLabel(ref.issue, label);
|
|
1102
|
+
} else {
|
|
1103
|
+
await deps.tracker.removeLabel(ref.issue, label);
|
|
1104
|
+
}
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1107
|
+
return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
|
|
1070
1108
|
}
|
|
1071
|
-
|
|
1109
|
+
|
|
1110
|
+
const outcome = `${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`;
|
|
1111
|
+
// Adding the queue label is the promotion: the author is asserting the file
|
|
1112
|
+
// lane right now, so the verb echoes the one admission will enforce — the
|
|
1113
|
+
// parsed file list, or the explicit fail-open note (#724). Best-effort: an
|
|
1114
|
+
// unreadable issue or thread must never block the promotion itself (the
|
|
1115
|
+
// label is the point), so a failed read falls back to the plain message.
|
|
1116
|
+
let echo: string | undefined;
|
|
1117
|
+
if (action === "add" && label === project.queueLabel) {
|
|
1118
|
+
echo = await laneEchoForIssue(deps, ref.issue);
|
|
1119
|
+
}
|
|
1120
|
+
return allow(
|
|
1121
|
+
echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
|
|
1122
|
+
undefined,
|
|
1123
|
+
ref.issue,
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The effective file lane admission will enforce for one issue, as the
|
|
1129
|
+
* one-line echo (#724): the body plus the whole comment thread, the same
|
|
1130
|
+
* inputs `effectiveLane` reads at admission. `undefined` when either read
|
|
1131
|
+
* fails or the tracker cannot produce the issue — the promotion is never
|
|
1132
|
+
* blocked by its own feedback.
|
|
1133
|
+
*/
|
|
1134
|
+
async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string | undefined> {
|
|
1135
|
+
let body: string;
|
|
1136
|
+
let comments: IssueComment[];
|
|
1137
|
+
try {
|
|
1138
|
+
const row = await deps.tracker.getIssue(issue);
|
|
1139
|
+
if (row === undefined) return undefined;
|
|
1140
|
+
body = row.body;
|
|
1141
|
+
comments = await deps.tracker.listComments(issue);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
return laneEcho(effectiveLane(body, comments));
|
|
1072
1146
|
}
|
|
1073
1147
|
|
|
1074
1148
|
async function releaseVerb(
|
|
@@ -1151,22 +1225,36 @@ async function releaseVerb(
|
|
|
1151
1225
|
// this release by holding. Fail-closed stays fail-closed: a row whose PR
|
|
1152
1226
|
// state cannot be read is still unsettled, and an unknown PR is still
|
|
1153
1227
|
// treated as open.
|
|
1228
|
+
//
|
|
1229
|
+
// The run facts are scoped to the released repo (#603): a run in another
|
|
1230
|
+
// routed repo cannot invalidate a tag on this one, so it must not gate this
|
|
1231
|
+
// release. A genuinely suite-wide shape — a pin or manifest that consumes
|
|
1232
|
+
// several repos — opts back into project-wide strictness through the named
|
|
1233
|
+
// `fleet-runs-settled` requirement, which widens the scope to every active
|
|
1234
|
+
// run. `no-open-prs` shares the scope: the branch being released is this
|
|
1235
|
+
// repo's, so an open PR elsewhere is not "against" it either.
|
|
1236
|
+
const fleetWide = policy.release.requires.includes("fleet-runs-settled");
|
|
1154
1237
|
const wantsRunFacts =
|
|
1155
1238
|
policy.release.requires.includes("runs-settled") ||
|
|
1239
|
+
fleetWide ||
|
|
1156
1240
|
policy.release.requires.includes("no-open-prs");
|
|
1157
|
-
|
|
1158
|
-
let
|
|
1241
|
+
const scoped = fleetWide ? active : active.filter((r) => r.repo === repoName);
|
|
1242
|
+
let unsettledRuns = scoped.length;
|
|
1243
|
+
let openPrs = scoped.filter((r) => r.prUrl !== undefined).length;
|
|
1159
1244
|
let liveWorkers: number | undefined;
|
|
1160
1245
|
let unconfirmedMerges: number | undefined;
|
|
1246
|
+
let blockingRuns: { repo: string; issue: number }[] | undefined;
|
|
1161
1247
|
if (wantsRunFacts) {
|
|
1162
1248
|
let unsettled = 0;
|
|
1163
1249
|
let open = 0;
|
|
1164
1250
|
let live = 0;
|
|
1165
1251
|
let unconfirmed = 0;
|
|
1166
|
-
|
|
1252
|
+
const blocking: { repo: string; issue: number }[] = [];
|
|
1253
|
+
for (const run of scoped) {
|
|
1167
1254
|
if (run.state === "claimed" || run.state === "running") {
|
|
1168
1255
|
unsettled += 1;
|
|
1169
1256
|
live += 1;
|
|
1257
|
+
blocking.push({ repo: run.repo, issue: run.issue });
|
|
1170
1258
|
// A live worker's PR is not yet confirmable; count it as open, the
|
|
1171
1259
|
// same fail-closed answer the old row-count gave it.
|
|
1172
1260
|
if (run.prUrl !== undefined) open += 1;
|
|
@@ -1175,6 +1263,7 @@ async function releaseVerb(
|
|
|
1175
1263
|
// pushed-pending / pushed-green: the worker is finished; the PR decides.
|
|
1176
1264
|
if (run.prUrl === undefined) {
|
|
1177
1265
|
unsettled += 1; // nothing to read; the sweep cannot settle it either
|
|
1266
|
+
blocking.push({ repo: run.repo, issue: run.issue });
|
|
1178
1267
|
continue;
|
|
1179
1268
|
}
|
|
1180
1269
|
let pr: PrState | undefined;
|
|
@@ -1185,6 +1274,7 @@ async function releaseVerb(
|
|
|
1185
1274
|
}
|
|
1186
1275
|
if (pr === "merged") continue; // settled in fact; not an open PR
|
|
1187
1276
|
unsettled += 1;
|
|
1277
|
+
blocking.push({ repo: run.repo, issue: run.issue });
|
|
1188
1278
|
if (pr === "open") open += 1;
|
|
1189
1279
|
else if (pr === undefined) {
|
|
1190
1280
|
// Cannot confirm the merge (or its absence): fail closed, and count
|
|
@@ -1200,12 +1290,14 @@ async function releaseVerb(
|
|
|
1200
1290
|
openPrs = open;
|
|
1201
1291
|
liveWorkers = live;
|
|
1202
1292
|
unconfirmedMerges = unconfirmed;
|
|
1293
|
+
blockingRuns = blocking;
|
|
1203
1294
|
}
|
|
1204
1295
|
const unmet = releaseRequirementRefusal(policy.release.requires, {
|
|
1205
1296
|
unsettledRuns,
|
|
1206
1297
|
openPrs,
|
|
1207
1298
|
...(liveWorkers === undefined ? {} : { liveWorkers }),
|
|
1208
1299
|
...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
|
|
1300
|
+
...(blockingRuns === undefined ? {} : { blockingRuns }),
|
|
1209
1301
|
queueDepth,
|
|
1210
1302
|
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
1211
1303
|
...(health?.verdict === "red" && health.detail !== undefined
|
|
@@ -1509,6 +1601,171 @@ async function prUpdateVerb(
|
|
|
1509
1601
|
return allow(`updated ${prUrl}.`, undefined, issue);
|
|
1510
1602
|
}
|
|
1511
1603
|
|
|
1604
|
+
/**
|
|
1605
|
+
* Return one green, run-owned pull request to its worker with blocking
|
|
1606
|
+
* findings (#677): the transport that replaces the manual close-PR →
|
|
1607
|
+
* comment-on-issue → unblock → continuation dance.
|
|
1608
|
+
*
|
|
1609
|
+
* Everything is recorded durably BEFORE anything is woken: the findings, the
|
|
1610
|
+
* exact reviewed head, the round number, and the target run/session. The wake
|
|
1611
|
+
* itself is the daemon's next dispatch pass (the verb has no handle on the
|
|
1612
|
+
* worker machinery, and the CLI path must behave exactly like the embedded
|
|
1613
|
+
* one), and the run row is reused — same branch, same PR, same attempt, same
|
|
1614
|
+
* session directory — so a revision round never creates a new attempt and
|
|
1615
|
+
* never touches the failed-attempt or continuation budgets.
|
|
1616
|
+
*
|
|
1617
|
+
* The duplicate in-flight guard has two halves: while a revision is queued
|
|
1618
|
+
* but not yet dispatched, the pending `review_revisions` row refuses the
|
|
1619
|
+
* second request atomically inside `claimReviewRevision`; once dispatched,
|
|
1620
|
+
* the run row itself is `running` (claimed from `pushed-green` by the
|
|
1621
|
+
* dispatch pass), which this verb refuses by name.
|
|
1622
|
+
*/
|
|
1623
|
+
async function prReviewVerb(
|
|
1624
|
+
deps: VerbDeps,
|
|
1625
|
+
project: ProjectConfig,
|
|
1626
|
+
channel: VerbChannel,
|
|
1627
|
+
args: Record<string, unknown>,
|
|
1628
|
+
refuse: Refuse,
|
|
1629
|
+
allow: Allow,
|
|
1630
|
+
): Promise<Verdict> {
|
|
1631
|
+
const prUrl = String(args["prUrl"]);
|
|
1632
|
+
const headSha = String(args["headSha"]);
|
|
1633
|
+
const findings = String(args["findings"]);
|
|
1634
|
+
const reason = String(args["reason"]) as ReviewReason;
|
|
1635
|
+
|
|
1636
|
+
// The run whose pushed-green row owns the PR — same resolution as merge: the
|
|
1637
|
+
// newest attempt that recorded this PR within the recent-history window. A
|
|
1638
|
+
// PR no run of this project opened (or one outside the routed repos) cannot
|
|
1639
|
+
// be returned to a worker this project can resume.
|
|
1640
|
+
const target = runForPr(deps, project.name, prUrl);
|
|
1641
|
+
if (target === undefined) {
|
|
1642
|
+
return refuse(
|
|
1643
|
+
"pr-not-this-run",
|
|
1644
|
+
`refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
|
|
1645
|
+
"A review revision acts on a run-owned PR only.",
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
if (!prInProjectRouting(project, prUrl)) {
|
|
1649
|
+
return refuse(
|
|
1650
|
+
"pr-not-this-run",
|
|
1651
|
+
`refused: ${prUrl} is not in ${project.name}'s routed repositories ` +
|
|
1652
|
+
`(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
|
|
1653
|
+
target.issue,
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
const issue = target.issue;
|
|
1657
|
+
|
|
1658
|
+
if (findings.trim() === "") {
|
|
1659
|
+
return refuse(
|
|
1660
|
+
"malformed-argument",
|
|
1661
|
+
"refused: conductor_pr_review needs a non-empty findings string — an empty revision returns the worker nothing to fix.",
|
|
1662
|
+
issue,
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// The revisable state is a settled green run. Any other state is a worker in
|
|
1667
|
+
// flight (the original run or an earlier revision) or a run whose PR no
|
|
1668
|
+
// longer waits on revision — the detail names the state field that produced
|
|
1669
|
+
// the refusal.
|
|
1670
|
+
if (target.state !== "pushed-green") {
|
|
1671
|
+
const live = target.state === "running" || target.state === "claimed";
|
|
1672
|
+
return refuse(
|
|
1673
|
+
"review-in-flight",
|
|
1674
|
+
`refused: run ${target.id} is ${target.state}, not pushed-green — ${
|
|
1675
|
+
live
|
|
1676
|
+
? `a worker (the original run or an earlier revision) is still live on ${prUrl}; wait for it to settle before returning it.`
|
|
1677
|
+
: `a review revision starts from a settled green run, and this one is ${target.state}.`
|
|
1678
|
+
}`,
|
|
1679
|
+
issue,
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
1684
|
+
if (paused !== undefined) return paused;
|
|
1685
|
+
|
|
1686
|
+
// Exact-head, re-read at request time: the orchestrator reviewed a specific
|
|
1687
|
+
// head, and a PR that moved since is not the thing it reviewed. Same verdict
|
|
1688
|
+
// the merge gate uses, so "green at headSha" here is the same fact the merge
|
|
1689
|
+
// would have required.
|
|
1690
|
+
let verification: PrVerification | undefined;
|
|
1691
|
+
try {
|
|
1692
|
+
verification = await deps.tracker.verifyPr(prUrl, headSha);
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1695
|
+
return refuse(
|
|
1696
|
+
"head-unresolvable",
|
|
1697
|
+
`refused: the live head of ${prUrl} could not be read (${why}).`,
|
|
1698
|
+
issue,
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
if (verification === undefined) {
|
|
1702
|
+
return refuse(
|
|
1703
|
+
"head-unresolvable",
|
|
1704
|
+
`refused: the live head of ${prUrl} could not be resolved. Refusing rather than returning a PR you did not re-read.`,
|
|
1705
|
+
issue,
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
if (verification.status !== "green") {
|
|
1709
|
+
const stale = verification.status === "failed" && isHeadMismatch(verification.reason);
|
|
1710
|
+
return refuse(
|
|
1711
|
+
stale ? "head-stale" : "checks-not-green",
|
|
1712
|
+
stale
|
|
1713
|
+
? `refused: ${verification.reason}. You reviewed ${headSha}; that is not what is on the branch now. Re-read the head and re-review.`
|
|
1714
|
+
: `refused: ${prUrl} is not green at ${headSha} — ${verification.reason}. A revision is returned to a green PR only.`,
|
|
1715
|
+
issue,
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// The hard bound (#678): the round ceiling is the dispatcher's, not the
|
|
1720
|
+
// orchestrator's to re-read from prose. A PR may be returned at most
|
|
1721
|
+
// `maxRounds` times per lifecycle; at the ceiling this refuses, and the
|
|
1722
|
+
// refusal states the two non-actions that replace a further round — leave
|
|
1723
|
+
// the PR open, record the unresolved findings and escalate once. The same
|
|
1724
|
+
// count `latestReviewRound` drives the renderer's `review-revision N`, so
|
|
1725
|
+
// the bound and the visible round can never disagree.
|
|
1726
|
+
const review = resolveReview(project);
|
|
1727
|
+
if (deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
|
|
1728
|
+
return refuse(
|
|
1729
|
+
"review-round-ceiling",
|
|
1730
|
+
`refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
|
|
1731
|
+
`(project "${project.name}" is ${review.strictness} strictness, ceiling ${review.maxRounds}). ` +
|
|
1732
|
+
"Leave the PR open, record the unresolved findings, and escalate once — a further revision round is not available.",
|
|
1733
|
+
issue,
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Durable, and refused atomically against a concurrent duplicate: the row is
|
|
1738
|
+
// persisted before any wake, and a second request for the same run bumps
|
|
1739
|
+
// into the pending row rather than racing it.
|
|
1740
|
+
const round = deps.store.latestReviewRound(project.name, target.id) + 1;
|
|
1741
|
+
const recorded = deps.store.createReviewRevision({
|
|
1742
|
+
project: project.name,
|
|
1743
|
+
runId: target.id,
|
|
1744
|
+
issue,
|
|
1745
|
+
prUrl,
|
|
1746
|
+
headSha,
|
|
1747
|
+
findings,
|
|
1748
|
+
round,
|
|
1749
|
+
reason,
|
|
1750
|
+
...(target.sessionFile === undefined ? {} : { sessionFile: target.sessionFile }),
|
|
1751
|
+
requestedAt: deps.now(),
|
|
1752
|
+
});
|
|
1753
|
+
if (recorded === undefined) {
|
|
1754
|
+
return refuse(
|
|
1755
|
+
"review-in-flight",
|
|
1756
|
+
`refused: a review revision is already pending for ${prUrl} (run ${target.id}); exactly one revision is in flight per PR.`,
|
|
1757
|
+
issue,
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return allow(
|
|
1762
|
+
`recorded review round ${round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
|
|
1763
|
+
`${target.sessionFile === undefined ? "" : ` (${target.sessionFile})`} on its next dispatch pass.`,
|
|
1764
|
+
undefined,
|
|
1765
|
+
issue,
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1512
1769
|
// ------------------------------------------------------------------ the listener
|
|
1513
1770
|
|
|
1514
1771
|
export interface VerbListener {
|