omp-conductor 0.12.0 → 0.14.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 +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
package/src/verbs/actions.ts
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* enough to actually write.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../gitops.ts";
|
|
18
|
-
import
|
|
17
|
+
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
|
|
18
|
+
import { ensureMirror } from "../worktree.ts";
|
|
19
|
+
import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
|
|
19
20
|
import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -47,6 +48,25 @@ export type CommandRunner = (
|
|
|
47
48
|
opts: { cwd?: string; env?: Record<string, string> },
|
|
48
49
|
) => Promise<CommandRun>;
|
|
49
50
|
|
|
51
|
+
export type MirrorPreparer = (repo: RepoTarget, mirrorRoot: string) => Promise<string>;
|
|
52
|
+
|
|
53
|
+
type CommitOutcome = { ok: true; sha: string } | { ok: false; stderr: string };
|
|
54
|
+
|
|
55
|
+
const FULL_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
56
|
+
const RELEASE_TAG_CONFIG = [
|
|
57
|
+
"-c",
|
|
58
|
+
"user.name=conductor",
|
|
59
|
+
"-c",
|
|
60
|
+
"user.email=conductor@invalid",
|
|
61
|
+
"-c",
|
|
62
|
+
"tag.gpgSign=false",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function commitFrom(stdout: string): string | undefined {
|
|
66
|
+
const value = stdout.trim().split(/\s+/, 1)[0];
|
|
67
|
+
return value !== undefined && FULL_COMMIT.test(value) ? value.toLowerCase() : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
const spawnCommand: CommandRunner = async (argv, opts) => {
|
|
51
71
|
const proc = Bun.spawn(argv, {
|
|
52
72
|
// Always a closed stream, matching the tracker adapter: a command that does
|
|
@@ -65,8 +85,11 @@ const spawnCommand: CommandRunner = async (argv, opts) => {
|
|
|
65
85
|
return { ok: code === 0 && !proc.signalCode, stdout, stderr };
|
|
66
86
|
};
|
|
67
87
|
|
|
68
|
-
function failed(run: CommandRun, argv: string[]):
|
|
69
|
-
return {
|
|
88
|
+
function failed(run: CommandRun, argv: string[]): { ok: false; stderr: string } {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
stderr: scrubUserinfo(run.stderr.trim() || `\`${argv.join(" ")}\` failed with no stderr`),
|
|
92
|
+
};
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
/**
|
|
@@ -75,7 +98,11 @@ function failed(run: CommandRun, argv: string[]): ActionOutcome {
|
|
|
75
98
|
* `run` is injected so the release and tracker paths can be exercised without
|
|
76
99
|
* a live repository; production callers never pass it.
|
|
77
100
|
*/
|
|
78
|
-
export function githubVerbActions(
|
|
101
|
+
export function githubVerbActions(
|
|
102
|
+
project: ProjectConfig,
|
|
103
|
+
run: CommandRunner = spawnCommand,
|
|
104
|
+
prepareMirror: MirrorPreparer = ensureMirror,
|
|
105
|
+
): VerbActions {
|
|
79
106
|
const env = (): Record<string, string> => credentialedEnv();
|
|
80
107
|
|
|
81
108
|
const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
|
|
@@ -84,6 +111,69 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
|
|
|
84
111
|
return result.ok ? { ok: true, detail: result.stdout.trim() || undefined } : failed(result, full);
|
|
85
112
|
};
|
|
86
113
|
|
|
114
|
+
const git = async (mirror: string, args: string[]): Promise<{ argv: string[]; result: CommandRun }> => {
|
|
115
|
+
const argv = ["git", "-C", mirror, ...args];
|
|
116
|
+
return { argv, result: await run(argv, { env: env() }) };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const readCommit = async (mirror: string, args: string[], fact: string): Promise<CommitOutcome> => {
|
|
120
|
+
const { argv, result } = await git(mirror, args);
|
|
121
|
+
if (!result.ok) return failed(result, argv);
|
|
122
|
+
const sha = commitFrom(result.stdout);
|
|
123
|
+
return sha === undefined
|
|
124
|
+
? { ok: false, stderr: `${fact} returned no full commit SHA` }
|
|
125
|
+
: { ok: true, sha };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const liveDefaultHead = (mirror: string, repo: RepoTarget): Promise<CommitOutcome> =>
|
|
129
|
+
readCommit(
|
|
130
|
+
mirror,
|
|
131
|
+
["ls-remote", "origin", `refs/heads/${repo.defaultBranch}`],
|
|
132
|
+
`live ${repo.defaultBranch} lookup`,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const remoteTagCommit = async (
|
|
136
|
+
mirror: string,
|
|
137
|
+
tag: string,
|
|
138
|
+
): Promise<{ ok: true; sha: string | undefined } | { ok: false; stderr: string }> => {
|
|
139
|
+
const directRef = `refs/tags/${tag}`;
|
|
140
|
+
const peeledRef = `${directRef}^{}`;
|
|
141
|
+
const { argv, result } = await git(mirror, ["ls-remote", "origin", directRef, peeledRef]);
|
|
142
|
+
if (!result.ok) return failed(result, argv);
|
|
143
|
+
if (result.stdout.trim() === "") return { ok: true, sha: undefined };
|
|
144
|
+
|
|
145
|
+
const refs = new Map(
|
|
146
|
+
result.stdout
|
|
147
|
+
.trim()
|
|
148
|
+
.split("\n")
|
|
149
|
+
.map((line) => line.trim().split(/\s+/, 2))
|
|
150
|
+
.filter((entry): entry is [string, string] => entry.length === 2)
|
|
151
|
+
.map(([sha, ref]) => [ref, sha] as const),
|
|
152
|
+
);
|
|
153
|
+
const sha = commitFrom(refs.get(peeledRef) ?? refs.get(directRef) ?? "");
|
|
154
|
+
return sha === undefined
|
|
155
|
+
? { ok: false, stderr: `remote tag ${tag} lookup returned no full commit SHA` }
|
|
156
|
+
: { ok: true, sha };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
|
|
160
|
+
ok: false,
|
|
161
|
+
stderr:
|
|
162
|
+
`refusing release: target ${target} is behind live ${repo.defaultBranch} ${live}. ` +
|
|
163
|
+
"Refresh the release status and retry so the tag contains every merged change.",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const releaseMirror = async (repo: RepoTarget): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> => {
|
|
167
|
+
try {
|
|
168
|
+
return { ok: true, path: await prepareMirror(repo, project.mirrorRoot) };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
stderr: `could not refresh ${repo.name}'s release mirror: ${err instanceof Error ? err.message : String(err)}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
87
177
|
return {
|
|
88
178
|
releasableShapes: GITHUB_RELEASABLE_SHAPES,
|
|
89
179
|
|
|
@@ -133,18 +223,158 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
|
|
|
133
223
|
if (execution.shape === "github-release") {
|
|
134
224
|
return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
|
|
135
225
|
}
|
|
136
|
-
// The two git shapes act on the project's
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
226
|
+
// The two git shapes act on the project's serialized, freshly fetched
|
|
227
|
+
// mirror. The live read on either side of tag creation closes the stale
|
|
228
|
+
// mirror and branch-moved-during-release windows (#258); the push path
|
|
229
|
+
// repeats the guard because a branch may move between the two verbs.
|
|
230
|
+
const prepared = await releaseMirror(execution.repo);
|
|
231
|
+
if (!prepared.ok) return prepared;
|
|
232
|
+
const mirror = prepared.path;
|
|
140
233
|
if (execution.shape === "git-tag") {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
234
|
+
const target = await readCommit(
|
|
235
|
+
mirror,
|
|
236
|
+
["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
|
|
237
|
+
`refreshed ${execution.repo.defaultBranch}`,
|
|
238
|
+
);
|
|
239
|
+
if (!target.ok) return target;
|
|
240
|
+
const liveBefore = await liveDefaultHead(mirror, execution.repo);
|
|
241
|
+
if (!liveBefore.ok) return liveBefore;
|
|
242
|
+
if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
|
|
243
|
+
|
|
244
|
+
const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]);
|
|
245
|
+
let previousSha: string | undefined;
|
|
246
|
+
if (localTag.result.ok) {
|
|
247
|
+
previousSha = commitFrom(localTag.result.stdout);
|
|
248
|
+
if (previousSha === undefined) {
|
|
249
|
+
return { ok: false, stderr: `tag ${tag} returned no full commit SHA` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const remoteTag = await remoteTagCommit(mirror, tag);
|
|
253
|
+
if (!remoteTag.ok) return remoteTag;
|
|
254
|
+
if (remoteTag.sha === target.sha) {
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
sha: target.sha,
|
|
258
|
+
detail: `tag ${tag} already exists at ${target.sha} and is already on origin`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (remoteTag.sha !== undefined) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
stderr:
|
|
265
|
+
`refusing release: ${tag} already exists on origin at ${remoteTag.sha}. ` +
|
|
266
|
+
"A published tag is never re-cut or moved; choose a new tag name.",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const repointed = previousSha !== undefined;
|
|
272
|
+
// An unattended daemon cannot depend on a host-level Git identity or a
|
|
273
|
+
// signing key with an interactive passphrase.
|
|
274
|
+
const tagged = await git(mirror, [
|
|
275
|
+
...RELEASE_TAG_CONFIG,
|
|
276
|
+
"tag",
|
|
277
|
+
...(repointed ? ["-f"] : []),
|
|
278
|
+
"-a",
|
|
279
|
+
tag,
|
|
280
|
+
"-m",
|
|
281
|
+
`release ${tag}`,
|
|
282
|
+
target.sha,
|
|
283
|
+
]);
|
|
284
|
+
if (!tagged.result.ok) return failed(tagged.result, tagged.argv);
|
|
285
|
+
|
|
286
|
+
const liveAfter = await liveDefaultHead(mirror, execution.repo);
|
|
287
|
+
if (!liveAfter.ok || liveAfter.sha !== target.sha) {
|
|
288
|
+
const rollback = previousSha !== undefined
|
|
289
|
+
? await git(mirror, [
|
|
290
|
+
...RELEASE_TAG_CONFIG,
|
|
291
|
+
"tag",
|
|
292
|
+
"-f",
|
|
293
|
+
"-a",
|
|
294
|
+
tag,
|
|
295
|
+
"-m",
|
|
296
|
+
`release ${tag}`,
|
|
297
|
+
previousSha,
|
|
298
|
+
])
|
|
299
|
+
: await git(mirror, ["tag", "-d", tag]);
|
|
300
|
+
const reason = liveAfter.ok
|
|
301
|
+
? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr
|
|
302
|
+
: `${repointed ? "re-pointed" : "created"} ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
|
|
303
|
+
const rollbackDetail = repointed
|
|
304
|
+
? rollback.result.ok
|
|
305
|
+
? ` The tag was restored to ${previousSha}.`
|
|
306
|
+
: ` WARNING: the tag could not be restored to ${previousSha}: ${failed(rollback.result, rollback.argv).stderr}`
|
|
307
|
+
: rollback.result.ok
|
|
308
|
+
? " The unpushed local tag was deleted."
|
|
309
|
+
: ` WARNING: the unpushed local tag could not be deleted: ${failed(rollback.result, rollback.argv).stderr}`;
|
|
310
|
+
return { ok: false, stderr: reason + rollbackDetail };
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
sha: target.sha,
|
|
315
|
+
detail: repointed
|
|
316
|
+
? `re-pointed unpushed ${tag} from ${previousSha} to ${target.sha}`
|
|
317
|
+
: `tagged ${tag} at ${target.sha}`,
|
|
318
|
+
};
|
|
144
319
|
}
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
320
|
+
|
|
321
|
+
const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
|
|
322
|
+
if (!target.ok) return target;
|
|
323
|
+
let tagSha = target.sha;
|
|
324
|
+
const live = await liveDefaultHead(mirror, execution.repo);
|
|
325
|
+
if (!live.ok) return live;
|
|
326
|
+
|
|
327
|
+
const remoteTag = await remoteTagCommit(mirror, tag);
|
|
328
|
+
if (!remoteTag.ok) return remoteTag;
|
|
329
|
+
if (remoteTag.sha === tagSha) {
|
|
330
|
+
return { ok: true, sha: tagSha, detail: `refs/tags/${tag} already on origin at ${tagSha}` };
|
|
331
|
+
}
|
|
332
|
+
if (remoteTag.sha !== undefined) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
stderr:
|
|
336
|
+
`refusing release: origin already has ${tag} at ${remoteTag.sha}, ` +
|
|
337
|
+
`which differs from the local tag at ${tagSha}. ` +
|
|
338
|
+
"A published tag is never force-moved; cut a new tag instead.",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let oldSha: string | undefined;
|
|
343
|
+
if (tagSha !== live.sha) {
|
|
344
|
+
const fresh = await readCommit(
|
|
345
|
+
mirror,
|
|
346
|
+
["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
|
|
347
|
+
`refreshed ${execution.repo.defaultBranch}`,
|
|
348
|
+
);
|
|
349
|
+
if (!fresh.ok) return fresh;
|
|
350
|
+
if (fresh.sha !== live.sha) return releaseTargetMoved(execution.repo, fresh.sha, live.sha);
|
|
351
|
+
|
|
352
|
+
oldSha = tagSha;
|
|
353
|
+
const retagged = await git(mirror, [
|
|
354
|
+
...RELEASE_TAG_CONFIG,
|
|
355
|
+
"tag",
|
|
356
|
+
"-f",
|
|
357
|
+
"-a",
|
|
358
|
+
tag,
|
|
359
|
+
"-m",
|
|
360
|
+
`release ${tag}`,
|
|
361
|
+
live.sha,
|
|
362
|
+
]);
|
|
363
|
+
if (!retagged.result.ok) return failed(retagged.result, retagged.argv);
|
|
364
|
+
tagSha = live.sha;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]);
|
|
368
|
+
return pushed.result.ok
|
|
369
|
+
? {
|
|
370
|
+
ok: true,
|
|
371
|
+
sha: tagSha,
|
|
372
|
+
detail:
|
|
373
|
+
oldSha === undefined
|
|
374
|
+
? `pushed refs/tags/${tag} at ${tagSha}`
|
|
375
|
+
: `re-pointed unpushed ${tag} from ${oldSha} to ${tagSha}; pushed refs/tags/${tag} at ${tagSha}`,
|
|
376
|
+
}
|
|
377
|
+
: failed(pushed.result, pushed.argv);
|
|
148
378
|
},
|
|
149
379
|
};
|
|
150
380
|
}
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -293,9 +293,10 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
293
293
|
mutating: false,
|
|
294
294
|
allowedRoles: ["worker", "orchestrator"],
|
|
295
295
|
description:
|
|
296
|
-
"Read one pull request's live state: is it open
|
|
297
|
-
"
|
|
298
|
-
"
|
|
296
|
+
"Read one pull request's live state and current head: is it open and are its checks green. " +
|
|
297
|
+
"Optionally compare that head with one the caller already observed. This is the daemon's own " +
|
|
298
|
+
"merge-gate verdict, not a summary of it. A poll, not a watcher — call it again after waiting. " +
|
|
299
|
+
"A worker may omit prUrl and gets its own run's.",
|
|
299
300
|
args: {
|
|
300
301
|
prUrl: {
|
|
301
302
|
type: "string",
|
|
@@ -304,10 +305,10 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
304
305
|
},
|
|
305
306
|
headSha: {
|
|
306
307
|
type: "string",
|
|
307
|
-
required:
|
|
308
|
+
required: false,
|
|
308
309
|
description:
|
|
309
|
-
"
|
|
310
|
-
"moved, the answer names both shas.",
|
|
310
|
+
"Optional: an expected head to compare against; omit it to read the current head. If the " +
|
|
311
|
+
"branch has moved, the answer names both shas.",
|
|
311
312
|
},
|
|
312
313
|
},
|
|
313
314
|
roleRefusalText: (role) => `conductor_pr_status is not open to a ${role} session.`,
|
package/src/verbs/server.ts
CHANGED
|
@@ -265,6 +265,10 @@ export interface ReleaseFacts {
|
|
|
265
265
|
openPrs: number;
|
|
266
266
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
267
267
|
queueDepth: number | undefined;
|
|
268
|
+
/** Current live-head workflow verdict for the released routed repository. */
|
|
269
|
+
baseCheck?: RunRecord["baseCheck"];
|
|
270
|
+
/** Evidence attached to a current red verdict. */
|
|
271
|
+
redBase?: string;
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
export function releaseRequirementRefusal(
|
|
@@ -286,6 +290,19 @@ export function releaseRequirementRefusal(
|
|
|
286
290
|
return `policy.release.requires includes queue-drained and ${facts.queueDepth} issue(s) are still queued`;
|
|
287
291
|
}
|
|
288
292
|
}
|
|
293
|
+
if (requirement === "base-branch-green") {
|
|
294
|
+
if (facts.baseCheck === "green") continue;
|
|
295
|
+
if (facts.baseCheck === "pending") {
|
|
296
|
+
return "policy.release.requires includes base-branch-green and the newest merge's base workflows are still pending";
|
|
297
|
+
}
|
|
298
|
+
if (facts.baseCheck === "red" || facts.baseCheck === "red-preexisting") {
|
|
299
|
+
return (
|
|
300
|
+
"policy.release.requires includes base-branch-green and the newest observed base branch is red" +
|
|
301
|
+
(facts.redBase === undefined ? "" : `: ${facts.redBase}`)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return "policy.release.requires includes base-branch-green and no green base-branch verdict is available; refusing rather than assuming the base is healthy";
|
|
305
|
+
}
|
|
289
306
|
if (requirement === "epic-children-closed") {
|
|
290
307
|
return "policy.release.requires includes epic-children-closed, which nothing in a release request names an epic for. The daemon cannot settle it, so it refuses: take the requirement off this project's policy, or cut this release by hand.";
|
|
291
308
|
}
|
|
@@ -423,19 +440,32 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
423
440
|
}
|
|
424
441
|
}
|
|
425
442
|
|
|
426
|
-
// 4. Stop always wins, and is re-read here
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
443
|
+
// 4. Stop always wins for work-starting mutations, and is re-read here
|
|
444
|
+
// rather than remembered. Reads are exempt. A run admitted before the
|
|
445
|
+
// pause may finish its own mutations (#174). Orchestrator completion
|
|
446
|
+
// verbs resolve their target run inside the handler, where they can prove
|
|
447
|
+
// that run also predates the pause (#251). Release remains available:
|
|
448
|
+
// its authority, grant, and runs-settled gates still fail closed below.
|
|
449
|
+
const orchestratorCompletion =
|
|
450
|
+
channel.kind === "orchestrator" &&
|
|
451
|
+
(verb === "conductor_pr_merge" ||
|
|
452
|
+
verb === "conductor_pr_update_branch" ||
|
|
453
|
+
verb === "conductor_pr_update" ||
|
|
454
|
+
verb === "conductor_label");
|
|
455
|
+
const stopped =
|
|
456
|
+
spec.mutating && verb !== "conductor_release" && !orchestratorCompletion
|
|
457
|
+
? deps.fleetStop()
|
|
458
|
+
: undefined;
|
|
434
459
|
if (stopped !== undefined) {
|
|
435
460
|
const at = deps.pausedAt();
|
|
436
461
|
const admittedBeforePause = run !== undefined && at !== undefined && run.startedAt < at;
|
|
437
462
|
if (!admittedBeforePause) {
|
|
438
|
-
return refuse(
|
|
463
|
+
return refuse(
|
|
464
|
+
"fleet-paused",
|
|
465
|
+
`refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
|
|
466
|
+
"Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
|
|
467
|
+
"conductor_pr_update, conductor_label) and conductor_release remain available.",
|
|
468
|
+
);
|
|
439
469
|
}
|
|
440
470
|
}
|
|
441
471
|
|
|
@@ -599,7 +629,7 @@ async function prCreateVerb(
|
|
|
599
629
|
* How far back to look is the recent-history cutoff `status` already uses: a
|
|
600
630
|
* pull request older than that is not one an orchestrator is mid-flight on.
|
|
601
631
|
*/
|
|
602
|
-
const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
632
|
+
export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
603
633
|
|
|
604
634
|
function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
|
|
605
635
|
return deps.store
|
|
@@ -607,6 +637,31 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
|
|
|
607
637
|
.find((candidate) => candidate.prUrl === prUrl);
|
|
608
638
|
}
|
|
609
639
|
|
|
640
|
+
/** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
|
|
641
|
+
function orchestratorPauseRefusal(
|
|
642
|
+
deps: VerbDeps,
|
|
643
|
+
channel: VerbChannel,
|
|
644
|
+
target: RunRecord | undefined,
|
|
645
|
+
refuse: Refuse,
|
|
646
|
+
): Verdict | undefined {
|
|
647
|
+
if (channel.kind !== "orchestrator") return undefined;
|
|
648
|
+
const stopped = deps.fleetStop();
|
|
649
|
+
if (stopped === undefined) return undefined;
|
|
650
|
+
const at = deps.pausedAt();
|
|
651
|
+
if (target !== undefined && at !== undefined && target.startedAt < at) return undefined;
|
|
652
|
+
const why =
|
|
653
|
+
target === undefined
|
|
654
|
+
? "this issue has no recorded run admitted before it."
|
|
655
|
+
: at === undefined
|
|
656
|
+
? "the pause timestamp cannot prove when this run started."
|
|
657
|
+
: "this run started after it.";
|
|
658
|
+
return refuse(
|
|
659
|
+
"fleet-paused",
|
|
660
|
+
`refused: ${stopped}. Under pause, this verb applies only to runs admitted before the pause; ${why}`,
|
|
661
|
+
target?.issue,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
610
665
|
async function prUpdateBranchVerb(
|
|
611
666
|
deps: VerbDeps,
|
|
612
667
|
project: ProjectConfig,
|
|
@@ -638,6 +693,9 @@ async function prUpdateBranchVerb(
|
|
|
638
693
|
}
|
|
639
694
|
}
|
|
640
695
|
|
|
696
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
697
|
+
if (paused !== undefined) return paused;
|
|
698
|
+
|
|
641
699
|
let state: PrState | undefined;
|
|
642
700
|
try {
|
|
643
701
|
state = await deps.tracker.prState(prUrl);
|
|
@@ -691,6 +749,9 @@ async function prMergeVerb(
|
|
|
691
749
|
return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
|
|
692
750
|
}
|
|
693
751
|
|
|
752
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
753
|
+
if (paused !== undefined) return paused;
|
|
754
|
+
|
|
694
755
|
// Taken before any network call, so two concurrent callers contend here
|
|
695
756
|
// rather than both spending a `gh` round trip and racing at the merge.
|
|
696
757
|
const holderId = randomUUID();
|
|
@@ -814,6 +875,14 @@ async function labelVerb(
|
|
|
814
875
|
);
|
|
815
876
|
}
|
|
816
877
|
|
|
878
|
+
const paused = orchestratorPauseRefusal(
|
|
879
|
+
deps,
|
|
880
|
+
channel,
|
|
881
|
+
deps.store.latestRun(project.name, ref.issue),
|
|
882
|
+
refuse,
|
|
883
|
+
);
|
|
884
|
+
if (paused !== undefined) return paused;
|
|
885
|
+
|
|
817
886
|
const label = String(args["label"]);
|
|
818
887
|
const vocabulary = labelVocabulary(project);
|
|
819
888
|
if (vocabulary.lifecycle.includes(label)) {
|
|
@@ -908,10 +977,18 @@ async function releaseVerb(
|
|
|
908
977
|
queueDepth = undefined;
|
|
909
978
|
}
|
|
910
979
|
}
|
|
980
|
+
const wantsBase = policy.release.requires.includes("base-branch-green");
|
|
981
|
+
const health = wantsBase
|
|
982
|
+
? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
|
|
983
|
+
: undefined;
|
|
911
984
|
const unmet = releaseRequirementRefusal(policy.release.requires, {
|
|
912
985
|
unsettledRuns: active.length,
|
|
913
986
|
openPrs: active.filter((r) => r.prUrl !== undefined).length,
|
|
914
987
|
queueDepth,
|
|
988
|
+
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
989
|
+
...(health?.verdict === "red" && health.detail !== undefined
|
|
990
|
+
? { redBase: health.detail }
|
|
991
|
+
: {}),
|
|
915
992
|
});
|
|
916
993
|
if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
|
|
917
994
|
|
|
@@ -991,10 +1068,11 @@ async function prStatusVerb(
|
|
|
991
1068
|
issue = target.issue;
|
|
992
1069
|
}
|
|
993
1070
|
|
|
994
|
-
const
|
|
1071
|
+
const askedHead = args["headSha"];
|
|
1072
|
+
const expectedHead = typeof askedHead === "string" ? askedHead : undefined;
|
|
995
1073
|
let verification: PrVerification | undefined;
|
|
996
1074
|
try {
|
|
997
|
-
verification = await deps.tracker.verifyPr(prUrl,
|
|
1075
|
+
verification = await deps.tracker.verifyPr(prUrl, expectedHead);
|
|
998
1076
|
} catch (err) {
|
|
999
1077
|
verification = undefined;
|
|
1000
1078
|
deps.log(`verb pr_status could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1007,7 +1085,7 @@ async function prStatusVerb(
|
|
|
1007
1085
|
issue,
|
|
1008
1086
|
);
|
|
1009
1087
|
}
|
|
1010
|
-
return allow(`${prUrl} at ${headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
|
|
1088
|
+
return allow(`${prUrl} at ${verification.headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
|
|
1011
1089
|
}
|
|
1012
1090
|
|
|
1013
1091
|
/**
|
|
@@ -1054,6 +1132,9 @@ async function prUpdateVerb(
|
|
|
1054
1132
|
if (target === undefined) {
|
|
1055
1133
|
return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
|
|
1056
1134
|
}
|
|
1135
|
+
|
|
1136
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
1137
|
+
if (paused !== undefined) return paused;
|
|
1057
1138
|
prUrl = asked;
|
|
1058
1139
|
issue = target.issue;
|
|
1059
1140
|
}
|