omp-conductor 0.18.0 → 0.18.2
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 +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/verbs/server.ts
CHANGED
|
@@ -42,13 +42,20 @@ import { randomUUID } from "node:crypto";
|
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
|
|
44
44
|
import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
|
|
45
|
-
|
|
45
|
+
// The mediated release is the drain's closing gesture (#791): a successful
|
|
46
|
+
// release ends this project's bounded release window, so the privileged half
|
|
47
|
+
// clears the record through the same operator surface the CLI uses.
|
|
48
|
+
import { cancelDrain } from "../fleet.ts";
|
|
49
|
+
import { effectiveLane, laneEcho, writeLaneSectionHeading } from "../admission.ts";
|
|
46
50
|
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
47
51
|
import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
48
52
|
import { releaseRefusal } from "../release-policy.ts";
|
|
53
|
+
import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
|
|
49
54
|
import { LIVE_STATES } from "../store.ts";
|
|
50
55
|
import type {
|
|
56
|
+
FileLane,
|
|
51
57
|
IssueComment,
|
|
58
|
+
IssueState,
|
|
52
59
|
OpenCloser,
|
|
53
60
|
PrState,
|
|
54
61
|
PrVerification,
|
|
@@ -58,13 +65,14 @@ import type {
|
|
|
58
65
|
RepoTarget,
|
|
59
66
|
ReviewReason,
|
|
60
67
|
RunRecord,
|
|
68
|
+
RunState,
|
|
61
69
|
Store,
|
|
62
70
|
Tracker,
|
|
63
71
|
VerbLedgerEntry,
|
|
64
72
|
VerbName,
|
|
65
73
|
VerbRefusal,
|
|
66
74
|
} from "../types.ts";
|
|
67
|
-
import { prUrlParts } from "../tracker/github.ts";
|
|
75
|
+
import { prUrlParts, GhPrMissingError } from "../tracker/github.ts";
|
|
68
76
|
import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
|
|
69
77
|
import {
|
|
70
78
|
peerVerdict,
|
|
@@ -224,11 +232,18 @@ export interface VerbDeps {
|
|
|
224
232
|
* every lifecycle label on the tracker was written by the conductor (#26).
|
|
225
233
|
* Telling a caller "not in the vocabulary" about a label it can plainly see on
|
|
226
234
|
* the issue teaches it to try harder; telling it "that transition is the
|
|
227
|
-
* daemon's" tells it to stop.
|
|
235
|
+
* daemon's" tells it to stop. The operator's park label is a third kind and a
|
|
236
|
+
* harder line than either: it is not "not in the vocabulary" (setup creates it,
|
|
237
|
+
* so that refusal would evaporate the day the vocabulary grows) and not the
|
|
238
|
+
* daemon's either — it is the operator's, and naming that owner is the point
|
|
239
|
+
* (#507). An orchestrator that could set or clear a park could hide work it
|
|
240
|
+
* does not want to groom.
|
|
228
241
|
*/
|
|
229
242
|
export interface LabelVocabulary {
|
|
230
243
|
grantable: string[];
|
|
231
244
|
lifecycle: string[];
|
|
245
|
+
/** Labels only the operator may change: the park gesture. */
|
|
246
|
+
operator: string[];
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
|
|
@@ -238,6 +253,7 @@ export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
|
|
|
238
253
|
...Object.keys(p.routing.repos).map((name) => `${p.routing.labelPrefix}${name}`),
|
|
239
254
|
],
|
|
240
255
|
lifecycle: [p.stateLabels.inProgress, p.stateLabels.blocked, p.stateLabels.failed],
|
|
256
|
+
operator: [p.stateLabels.backlog],
|
|
241
257
|
};
|
|
242
258
|
}
|
|
243
259
|
|
|
@@ -308,6 +324,12 @@ export interface ReleaseFacts {
|
|
|
308
324
|
blockingRuns?: { repo: string; issue: number }[];
|
|
309
325
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
310
326
|
queueDepth: number | undefined;
|
|
327
|
+
/**
|
|
328
|
+
* The project this release targets, so a runs-settled refusal can name that
|
|
329
|
+
* project's own bounded drain command as the remedy (#791). Absent when the
|
|
330
|
+
* caller cannot name one; the remedy then omits --project.
|
|
331
|
+
*/
|
|
332
|
+
project?: string;
|
|
311
333
|
/** Current live-head workflow verdict for the released routed repository. */
|
|
312
334
|
baseCheck?: RunRecord["baseCheck"];
|
|
313
335
|
/** Evidence attached to a current red verdict. */
|
|
@@ -342,10 +364,20 @@ export function releaseRequirementRefusal(
|
|
|
342
364
|
facts.blockingRuns === undefined || facts.blockingRuns.length === 0
|
|
343
365
|
? ""
|
|
344
366
|
: ` — blocking: ${facts.blockingRuns.map((r) => `${r.repo} #${r.issue}`).join(", ")}`;
|
|
367
|
+
// The bounded drain is the release-window remedy (#484): it pauses only
|
|
368
|
+
// claiming while the settle pass keeps running, and expires on an
|
|
369
|
+
// absolute deadline. It is the one command that reaches this gate
|
|
370
|
+
// honestly — not hold, which stops every pass, and no queue-label
|
|
371
|
+
// churn, which writes tracker state (#791).
|
|
372
|
+
const drain =
|
|
373
|
+
". Settle the fleet with a bounded drain: " +
|
|
374
|
+
`omp-conductor drain start${facts.project === undefined ? "" : ` --project ${facts.project}`} --until <deadline>` +
|
|
375
|
+
" — claiming pauses while settlement runs, and admission resumes at the deadline on its own.";
|
|
345
376
|
return (
|
|
346
377
|
`policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
|
|
347
378
|
(parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
|
|
348
|
-
blocking
|
|
379
|
+
blocking +
|
|
380
|
+
drain
|
|
349
381
|
);
|
|
350
382
|
}
|
|
351
383
|
if (requirement === "no-open-prs" && facts.openPrs > 0) {
|
|
@@ -521,6 +553,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
521
553
|
verb === "conductor_pr_update_branch" ||
|
|
522
554
|
verb === "conductor_pr_update" ||
|
|
523
555
|
verb === "conductor_pr_review" ||
|
|
556
|
+
verb === "conductor_pr_recover" ||
|
|
524
557
|
verb === "conductor_label");
|
|
525
558
|
const stopped =
|
|
526
559
|
spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
|
|
@@ -534,7 +567,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
534
567
|
"fleet-paused",
|
|
535
568
|
`refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
|
|
536
569
|
"Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
|
|
537
|
-
"conductor_pr_update, conductor_label), conductor_release and conductor_install remain available.",
|
|
570
|
+
"conductor_pr_update, conductor_pr_recover, conductor_label), conductor_release and conductor_install remain available.",
|
|
538
571
|
);
|
|
539
572
|
}
|
|
540
573
|
}
|
|
@@ -558,6 +591,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
558
591
|
return installVerb(deps, project, channel, args, refuse, allow);
|
|
559
592
|
case "conductor_pr_review":
|
|
560
593
|
return prReviewVerb(deps, project, channel, args, refuse, allow);
|
|
594
|
+
case "conductor_pr_recover":
|
|
595
|
+
return prRecoverVerb(deps, project, channel, args, refuse, allow);
|
|
561
596
|
case "conductor_pr_status":
|
|
562
597
|
return prStatusVerb(deps, project, channel, args, refuse, allow);
|
|
563
598
|
}
|
|
@@ -702,9 +737,9 @@ async function prCreateVerb(
|
|
|
702
737
|
*
|
|
703
738
|
* How far back to look is the recent-history cutoff `status` already uses: a
|
|
704
739
|
* pull request older than that is not one an orchestrator is mid-flight on.
|
|
740
|
+
* The bound itself is shared with the review watch (`PR_LOOKUP_WINDOW_MS` in
|
|
741
|
+
* `decisions.ts`), so verb and condition resolve ownership identically.
|
|
705
742
|
*/
|
|
706
|
-
export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
707
|
-
|
|
708
743
|
function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
|
|
709
744
|
// Every attempt that recorded this PR, newest first — not `recentRuns`'
|
|
710
745
|
// newest-attempt-per-issue view. The row a PR belongs to is the newest
|
|
@@ -1073,6 +1108,18 @@ async function labelVerb(
|
|
|
1073
1108
|
|
|
1074
1109
|
const label = String(args["label"]);
|
|
1075
1110
|
const vocabulary = labelVocabulary(project);
|
|
1111
|
+
if (vocabulary.operator.includes(label)) {
|
|
1112
|
+
// Harder than the lifecycle refusal: add and remove alike, and never
|
|
1113
|
+
// learned away by growing the vocabulary. The park is the operator's
|
|
1114
|
+
// gesture — an orchestrator that can park work can hide work it does not
|
|
1115
|
+
// want to groom (#507).
|
|
1116
|
+
return refuse(
|
|
1117
|
+
"label-is-operator-owned",
|
|
1118
|
+
`refused: ${label} is the operator's park label and only the operator may set or clear it. ` +
|
|
1119
|
+
"No session — orchestrator or worker — may add or remove it; use the tracker directly.",
|
|
1120
|
+
ref.issue,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1076
1123
|
if (vocabulary.lifecycle.includes(label)) {
|
|
1077
1124
|
return refuse(
|
|
1078
1125
|
"label-is-lifecycle",
|
|
@@ -1090,6 +1137,36 @@ async function labelVerb(
|
|
|
1090
1137
|
}
|
|
1091
1138
|
|
|
1092
1139
|
const action = args["action"] === "remove" ? "remove" : "add";
|
|
1140
|
+
const outcome = `${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`;
|
|
1141
|
+
// Adding the queue label is the promotion: the author is asserting the file
|
|
1142
|
+
// lane right now, so the verb echoes the one admission will enforce — the
|
|
1143
|
+
// parsed file list, or the explicit fail-open note (#724). A clearly
|
|
1144
|
+
// delimited write-lane section that parsed nothing REFUSES the promotion
|
|
1145
|
+
// instead of failing open (#825): the echo must never claim fail-open for a
|
|
1146
|
+
// heading that plainly tried to declare one, and failing open beside
|
|
1147
|
+
// overlapping work is the exact gap the interlock exists to close. Best
|
|
1148
|
+
// effort still holds for reads: an unreadable issue or thread can never
|
|
1149
|
+
// block the promotion itself (the label is the point), so a failed read
|
|
1150
|
+
// falls back to the plain message.
|
|
1151
|
+
let echo: string | undefined;
|
|
1152
|
+
let malformed: string | undefined;
|
|
1153
|
+
if (action === "add" && label === project.queueLabel) {
|
|
1154
|
+
const lane = await laneForEcho(deps, ref.issue);
|
|
1155
|
+
if (lane !== undefined) {
|
|
1156
|
+
malformed = lane.malformed;
|
|
1157
|
+
echo = laneEcho(lane.lane);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (malformed !== undefined) {
|
|
1161
|
+
return refuse(
|
|
1162
|
+
"file-lane-unparseable",
|
|
1163
|
+
`refused: ${label} was not added — #${ref.issue} declares a write-lane section (${malformed}) but no ` +
|
|
1164
|
+
"path-like files parsed under it, so admission would fail open beside overlapping work. " +
|
|
1165
|
+
'Name the files as backticked bullets directly under the heading (e.g. "- `omp/src/a.ts` — what ' +
|
|
1166
|
+
'changes"), or remove the heading, then add the queue label again.',
|
|
1167
|
+
ref.issue,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1093
1170
|
// The queue label is the fleet's lock, so it must not ride the GraphQL
|
|
1094
1171
|
// surface a provider outage just broke: `gh issue edit` is a GraphQL
|
|
1095
1172
|
// mutation in gh 2.86, and #642 comment 4 measured a label withdrawal
|
|
@@ -1107,16 +1184,6 @@ async function labelVerb(
|
|
|
1107
1184
|
return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
|
|
1108
1185
|
}
|
|
1109
1186
|
|
|
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
1187
|
return allow(
|
|
1121
1188
|
echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
|
|
1122
1189
|
undefined,
|
|
@@ -1126,12 +1193,15 @@ async function labelVerb(
|
|
|
1126
1193
|
|
|
1127
1194
|
/**
|
|
1128
1195
|
* The effective file lane admission will enforce for one issue, as the
|
|
1129
|
-
* one-line echo (#724)
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
* blocked by its own feedback.
|
|
1196
|
+
* one-line echo (#724), plus the malformed write-lane section marker (#825):
|
|
1197
|
+
* the body plus the whole comment thread, the same inputs `effectiveLane`
|
|
1198
|
+
* reads at admission. `undefined` when either read fails or the tracker cannot
|
|
1199
|
+
* produce the issue — the promotion is never blocked by its own feedback.
|
|
1133
1200
|
*/
|
|
1134
|
-
async function
|
|
1201
|
+
async function laneForEcho(deps: VerbDeps, issue: number): Promise<
|
|
1202
|
+
| { lane: FileLane | undefined; malformed: string | undefined }
|
|
1203
|
+
| undefined
|
|
1204
|
+
> {
|
|
1135
1205
|
let body: string;
|
|
1136
1206
|
let comments: IssueComment[];
|
|
1137
1207
|
try {
|
|
@@ -1142,7 +1212,15 @@ async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string |
|
|
|
1142
1212
|
} catch {
|
|
1143
1213
|
return undefined;
|
|
1144
1214
|
}
|
|
1145
|
-
|
|
1215
|
+
const lane = effectiveLane(body, comments);
|
|
1216
|
+
if (lane !== undefined) return { lane, malformed: undefined };
|
|
1217
|
+
// No declaration parsed anywhere. If a clearly delimited write-lane section
|
|
1218
|
+
// exists anyway, the section tried to declare and failed — the echo must
|
|
1219
|
+
// refuse, never claim fail-open.
|
|
1220
|
+
const heading =
|
|
1221
|
+
writeLaneSectionHeading(body) ??
|
|
1222
|
+
comments.map((c) => writeLaneSectionHeading(c.body)).find((h) => h !== undefined);
|
|
1223
|
+
return { lane: undefined, malformed: heading };
|
|
1146
1224
|
}
|
|
1147
1225
|
|
|
1148
1226
|
async function releaseVerb(
|
|
@@ -1299,6 +1377,7 @@ async function releaseVerb(
|
|
|
1299
1377
|
...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
|
|
1300
1378
|
...(blockingRuns === undefined ? {} : { blockingRuns }),
|
|
1301
1379
|
queueDepth,
|
|
1380
|
+
project: project.name,
|
|
1302
1381
|
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
1303
1382
|
...(health?.verdict === "red" && health.detail !== undefined
|
|
1304
1383
|
? { redBase: health.detail }
|
|
@@ -1326,6 +1405,15 @@ async function releaseVerb(
|
|
|
1326
1405
|
if (!outcome.ok) {
|
|
1327
1406
|
return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
|
|
1328
1407
|
}
|
|
1408
|
+
// The bounded drain is a release window: it stays latched until the release
|
|
1409
|
+
// work it fences has actually completed, then clears so admission resumes
|
|
1410
|
+
// without a second operator action (#791). Every refusal and failed action
|
|
1411
|
+
// above returned without touching it. The release-window boundary is the
|
|
1412
|
+
// *terminal* act: a version-bump preparation — whether opened, already
|
|
1413
|
+
// prepared, or merged — explicitly leaves git-tag pending (review #1 on
|
|
1414
|
+
// #823), so clearing there would admit a run that makes that follow-up
|
|
1415
|
+
// git-tag fail runs-settled and forces a second drain. Only the final
|
|
1416
|
+
// cut/publish/deploy allow below clears the drain.
|
|
1329
1417
|
if (shape === "version-bump-pr") {
|
|
1330
1418
|
if (outcome.review === undefined) {
|
|
1331
1419
|
return allow(
|
|
@@ -1397,6 +1485,7 @@ async function releaseVerb(
|
|
|
1397
1485
|
deps.store.releaseMergeLock(project.name, holderId);
|
|
1398
1486
|
}
|
|
1399
1487
|
}
|
|
1488
|
+
cancelDrain(project.name);
|
|
1400
1489
|
return allow(
|
|
1401
1490
|
`cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
|
|
1402
1491
|
outcome.sha,
|
|
@@ -1614,12 +1703,41 @@ async function prUpdateVerb(
|
|
|
1614
1703
|
* session directory — so a revision round never creates a new attempt and
|
|
1615
1704
|
* never touches the failed-attempt or continuation budgets.
|
|
1616
1705
|
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
* the
|
|
1621
|
-
*
|
|
1706
|
+
* A second orchestrator axis finishing against the same fixed head before the
|
|
1707
|
+
* first revision dispatches folds its finding into the pending revision
|
|
1708
|
+
* (#786) rather than being refused: one round, one row, one worker attempt,
|
|
1709
|
+
* and the resumed session reads both findings. The fold is only for the same
|
|
1710
|
+
* reviewed head; a pending revision at a different head is a review of a
|
|
1711
|
+
* moved PR and is refused, as is anything once the revision has dispatched.
|
|
1712
|
+
*
|
|
1713
|
+
* The duplicate in-flight guard therefore has two halves: while a revision is
|
|
1714
|
+
* queued but not yet dispatched, the pending `review_revisions` row folds a
|
|
1715
|
+
* same-head finding into itself atomically inside `enqueueReviewRevision` and
|
|
1716
|
+
* refuses a different head; once dispatched, the run row itself is `running`
|
|
1717
|
+
* (claimed from a revisable terminal state by the dispatch pass), which this
|
|
1718
|
+
* verb refuses by name — an already-running worker's prompt is never silently
|
|
1719
|
+
* changed.
|
|
1720
|
+
*/
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* The run states a review revision may start from (#795).
|
|
1724
|
+
*
|
|
1725
|
+
* A revision round resumes the exact run whose row owns the PR, so the
|
|
1726
|
+
* revisable states are exactly the terminal runs that pushed one: a settled
|
|
1727
|
+
* `pushed-green` row, or a `failed` / `killed` row — a run that capped or
|
|
1728
|
+
* failed *after* pushing a green PR. The PR is the durable artefact, the
|
|
1729
|
+
* exact-head green verification in the verb is the gate on "green at the
|
|
1730
|
+
* reviewed SHA", and a terminal row proves no worker is in flight, so the
|
|
1731
|
+
* findings are returned without the close-PR → unblock → continuation dance.
|
|
1732
|
+
*
|
|
1733
|
+
* Closed on purpose: a live row (`running` / `claimed`) is already doing its
|
|
1734
|
+
* own work, a `pushed-pending` PR is not green yet, and a `blocked` /
|
|
1735
|
+
* `orphaned` / `stopped` / `merged` row is not work returned for revision.
|
|
1736
|
+
* The set itself is the shared `REVISABLE_RUN_STATES` in `decisions.ts` —
|
|
1737
|
+
* the one definition the review verb and the `pr-review-ready` watch gate on,
|
|
1738
|
+
* so the two can never drift (#844).
|
|
1622
1739
|
*/
|
|
1740
|
+
|
|
1623
1741
|
async function prReviewVerb(
|
|
1624
1742
|
deps: VerbDeps,
|
|
1625
1743
|
project: ProjectConfig,
|
|
@@ -1633,18 +1751,22 @@ async function prReviewVerb(
|
|
|
1633
1751
|
const findings = String(args["findings"]);
|
|
1634
1752
|
const reason = String(args["reason"]) as ReviewReason;
|
|
1635
1753
|
|
|
1636
|
-
// The
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
|
|
1641
|
-
|
|
1754
|
+
// The shared review-readiness gate (#844, #870): the newest project-owned
|
|
1755
|
+
// run that recorded the PR — a stopped duplicate is transparent to that
|
|
1756
|
+
// selection (#870) — in a revisable state; the same predicate the
|
|
1757
|
+
// `pr-review-ready` watch reads, so the verb and the condition resolve
|
|
1758
|
+
// ownership and revisability identically. A PR no run of this project
|
|
1759
|
+
// opened (or one outside the routed repos) cannot be returned to a worker
|
|
1760
|
+
// this project can resume.
|
|
1761
|
+
const readiness = prReviewReadiness(deps.store, project.name, prUrl, deps.now());
|
|
1762
|
+
if (readiness.kind === "no-owner") {
|
|
1642
1763
|
return refuse(
|
|
1643
1764
|
"pr-not-this-run",
|
|
1644
1765
|
`refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
|
|
1645
1766
|
"A review revision acts on a run-owned PR only.",
|
|
1646
1767
|
);
|
|
1647
1768
|
}
|
|
1769
|
+
const target = readiness.run;
|
|
1648
1770
|
if (!prInProjectRouting(project, prUrl)) {
|
|
1649
1771
|
return refuse(
|
|
1650
1772
|
"pr-not-this-run",
|
|
@@ -1663,18 +1785,22 @@ async function prReviewVerb(
|
|
|
1663
1785
|
);
|
|
1664
1786
|
}
|
|
1665
1787
|
|
|
1666
|
-
// The revisable state is a
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1669
|
-
// the
|
|
1670
|
-
|
|
1788
|
+
// The revisable state is a run that pushed a green PR and is not live: a
|
|
1789
|
+
// settled `pushed-green` row, or a `failed` / `killed` row — a run that
|
|
1790
|
+
// capped or failed after the push (#795). The exact-head verification below
|
|
1791
|
+
// still re-reads the PR and insists it is green at the reviewed SHA, so
|
|
1792
|
+
// admitting a capped row here never reviews a PR that stopped being green.
|
|
1793
|
+
// Any other state is a worker in flight (the original run or an earlier
|
|
1794
|
+
// revision) or a run whose PR no longer waits on revision — the detail names
|
|
1795
|
+
// the state field that produced the refusal.
|
|
1796
|
+
if (readiness.kind !== "ready") {
|
|
1671
1797
|
const live = target.state === "running" || target.state === "claimed";
|
|
1672
1798
|
return refuse(
|
|
1673
1799
|
"review-in-flight",
|
|
1674
|
-
`refused: run ${target.id} is ${target.state}, not
|
|
1800
|
+
`refused: run ${target.id} is ${target.state}, not a settled green run — ${
|
|
1675
1801
|
live
|
|
1676
1802
|
? `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
|
|
1803
|
+
: `a review revision starts from a run that pushed a green PR, and this one is ${target.state}.`
|
|
1678
1804
|
}`,
|
|
1679
1805
|
issue,
|
|
1680
1806
|
);
|
|
@@ -1723,8 +1849,14 @@ async function prReviewVerb(
|
|
|
1723
1849
|
// the PR open, record the unresolved findings and escalate once. The same
|
|
1724
1850
|
// count `latestReviewRound` drives the renderer's `review-revision N`, so
|
|
1725
1851
|
// the bound and the visible round can never disagree.
|
|
1852
|
+
//
|
|
1853
|
+
// A finding that folds into an already-pending revision opens no round, so
|
|
1854
|
+
// the ceiling never binds an append — only a genuine new round counts. The
|
|
1855
|
+
// store is the single authority on the fold; this read exists only to
|
|
1856
|
+
// decide whether the ceiling gates this call.
|
|
1726
1857
|
const review = resolveReview(project);
|
|
1727
|
-
|
|
1858
|
+
const pendingForRun = deps.store.pendingReviewForRun(project.name, target.id);
|
|
1859
|
+
if (pendingForRun === undefined && deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
|
|
1728
1860
|
return refuse(
|
|
1729
1861
|
"review-round-ceiling",
|
|
1730
1862
|
`refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
|
|
@@ -1734,11 +1866,29 @@ async function prReviewVerb(
|
|
|
1734
1866
|
);
|
|
1735
1867
|
}
|
|
1736
1868
|
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1869
|
+
// The run row is re-read synchronously, immediately before the durable
|
|
1870
|
+
// enqueue and with no await in between: the daemon's dispatch pass
|
|
1871
|
+
// (`pushed-green` → `running`) cannot land inside a single synchronous
|
|
1872
|
+
// block, so a revision that dispatched while the live-head verification
|
|
1873
|
+
// above was in flight is refused here rather than racing the worker that
|
|
1874
|
+
// now owns the PR — an already-running worker's prompt is never silently
|
|
1875
|
+
// changed.
|
|
1876
|
+
const fresh = deps.store.getRun(target.id);
|
|
1877
|
+
if (fresh === undefined || REVISABLE_RUN_STATES[fresh.state] !== true) {
|
|
1878
|
+
return refuse(
|
|
1879
|
+
"review-in-flight",
|
|
1880
|
+
`refused: run ${target.id} is no longer revisable on ${prUrl} (now ${fresh?.state ?? "gone"}) — ` +
|
|
1881
|
+
"the pending revision has dispatched to a worker. " +
|
|
1882
|
+
"Wait for it to settle, then return the finding as its own later review round.",
|
|
1883
|
+
issue,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Durable, and decided atomically against a concurrent same-head request:
|
|
1888
|
+
// the same transaction that records a new round also folds a same-head
|
|
1889
|
+
// finding into the one already pending, and the merge never overwrites.
|
|
1740
1890
|
const round = deps.store.latestReviewRound(project.name, target.id) + 1;
|
|
1741
|
-
const
|
|
1891
|
+
const enqueued = deps.store.enqueueReviewRevision({
|
|
1742
1892
|
project: project.name,
|
|
1743
1893
|
runId: target.id,
|
|
1744
1894
|
issue,
|
|
@@ -1747,20 +1897,565 @@ async function prReviewVerb(
|
|
|
1747
1897
|
findings,
|
|
1748
1898
|
round,
|
|
1749
1899
|
reason,
|
|
1750
|
-
...(
|
|
1900
|
+
...(fresh.sessionFile === undefined ? {} : { sessionFile: fresh.sessionFile }),
|
|
1751
1901
|
requestedAt: deps.now(),
|
|
1752
1902
|
});
|
|
1753
|
-
if (
|
|
1903
|
+
if (enqueued.kind === "refused") {
|
|
1754
1904
|
return refuse(
|
|
1755
1905
|
"review-in-flight",
|
|
1756
|
-
`refused: a review revision is already pending for ${prUrl} (run ${target.id})
|
|
1906
|
+
`refused: a review revision is already pending for ${prUrl} (run ${target.id}) at a different head — ` +
|
|
1907
|
+
"amend nothing, because that round reviewed a moved PR. Wait for it to dispatch and settle, then return the finding as its own later review round.",
|
|
1908
|
+
issue,
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
const revision = enqueued.record;
|
|
1912
|
+
return allow(
|
|
1913
|
+
enqueued.kind === "created"
|
|
1914
|
+
? `recorded review round ${revision.round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
|
|
1915
|
+
`${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass.`
|
|
1916
|
+
: `appended a finding to the pending review round ${revision.round} for ${prUrl} at ${headSha}; ` +
|
|
1917
|
+
"the daemon will resume run " +
|
|
1918
|
+
`${target.id}'s session${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass ` +
|
|
1919
|
+
"and the resumed worker reads both findings.",
|
|
1920
|
+
undefined,
|
|
1921
|
+
issue,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* The run states a missing-PR recovery may act on (#806).
|
|
1927
|
+
*
|
|
1928
|
+
* Recovery exists for a run whose worker finished and recorded an exact head
|
|
1929
|
+
* but whose PR never materialised — exactly the states the settlement sweep
|
|
1930
|
+
* owns: `pushed-pending` (the #777/#809 ghosts) and `pushed-green`. Those
|
|
1931
|
+
* rows sit in the store's active set, so the sweep verifies the recovered
|
|
1932
|
+
* PR's checks on its next tick (`pushed-pending` → `pushed-green`) and
|
|
1933
|
+
* review/merge settlement resumes normally. A terminal row outside this set —
|
|
1934
|
+
* failed/killed/stopped/orphaned/blocked — has its own prescribed
|
|
1935
|
+
* reconciliation (continuation or review revision, operator stop, restart
|
|
1936
|
+
* reconciliation, unblock), and receiving a PR would strand it: nothing would
|
|
1937
|
+
* ever verify, review or merge it.
|
|
1938
|
+
*
|
|
1939
|
+
* Closed on purpose, like {@link REVISABLE_RUN_STATES}: recovery may never
|
|
1940
|
+
* widen into a state the daemon's own sweep does not revisit.
|
|
1941
|
+
*/
|
|
1942
|
+
const RECOVERABLE_RUN_STATES: Record<string, true> = {
|
|
1943
|
+
"pushed-pending": true,
|
|
1944
|
+
"pushed-green": true,
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
/** A complete commit SHA, as current workers record it. */
|
|
1948
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* A legacy abbreviated commit SHA (#836): the `git rev-parse --short` output
|
|
1952
|
+
* an older worker generation recorded instead of the full SHA. Git defaults
|
|
1953
|
+
* to 7 hex characters and lengthens the abbreviation when 7 is ambiguous, so
|
|
1954
|
+
* a legacy record is always at least 7 hex; 4–6 hex are git-legal
|
|
1955
|
+
* abbreviations but cannot be legacy records, and at that length the prefix
|
|
1956
|
+
* evidence is too weak for an identity recovery to rest on. Anything shorter
|
|
1957
|
+
* than git's own abbreviation minimum (4) is not a usable object id at all —
|
|
1958
|
+
* ambiguous, and it fails closed.
|
|
1959
|
+
*/
|
|
1960
|
+
const ABBREVIATED_HEAD_SHA = /^[0-9a-f]{7,39}$/i;
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* The durable PR field's worker settlement sentinel (#866). A run row whose
|
|
1964
|
+
* settlement recorded "no PR observed yet" carries the literal `pending`
|
|
1965
|
+
* instead of a URL. It asserts nothing about any GitHub pull request — there
|
|
1966
|
+
* is no PR to read — so recovery normalizes it to absence and re-proves the
|
|
1967
|
+
* no-duplicate shape before opening a PR, exactly like a row that recorded no
|
|
1968
|
+
* URL at all. Querying GitHub with it would only produce the tracker's
|
|
1969
|
+
* fail-closed `undefined`, which recovery would misread as an unreadable-but-
|
|
1970
|
+
* real PR and refuse as retryable forever.
|
|
1971
|
+
*/
|
|
1972
|
+
const PR_PENDING_SENTINEL = "pending";
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Open (or adopt) the missing pull request for a settled run whose branch was
|
|
1976
|
+
* preserved (#806).
|
|
1977
|
+
*
|
|
1978
|
+
* A worker that pushed and recorded an exact head can still leave the run
|
|
1979
|
+
* spent: the PR it claimed never existed (a guessed URL — #779), or the run
|
|
1980
|
+
* settled before a PR was ever opened. Nothing else in the verb surface can
|
|
1981
|
+
* act then — the mediated PR verbs resolve the PR from a URL some run
|
|
1982
|
+
* recorded (`conductor_pr_review` refuses "not a PR any run opened"), the
|
|
1983
|
+
* worker-only create verb is gone with the worker, and the package-floor
|
|
1984
|
+
* fallback is a fresh clone and re-push, which republishes code. This is the
|
|
1985
|
+
* strictly smaller act: publish the pull request the run's work already
|
|
1986
|
+
* earned, at the exact head the daemon recorded.
|
|
1987
|
+
*
|
|
1988
|
+
* Every identity the worker's own `conductor_pr_create` read off its live
|
|
1989
|
+
* channel is re-proven here in its absence:
|
|
1990
|
+
* - the run is terminal — no worker is live on it (`claimed`/`running`
|
|
1991
|
+
* refuse, and a merged row is resolved, not recoverable);
|
|
1992
|
+
* - the routed repository is still routed, and the branch comes from the run
|
|
1993
|
+
* row, never from an argument — `branch`/`base`/`headSha` are assertions
|
|
1994
|
+
* exactly as on the worker verb, and a disagreement is a refusal, never a
|
|
1995
|
+
* redirect;
|
|
1996
|
+
* - the remote branch still exists at the recorded identity: a 40-hex head
|
|
1997
|
+
* must equal the live branch head exactly, and a legacy abbreviated head
|
|
1998
|
+
* (7–39 hex, #836) may only canonicalize against the fixed routed branch —
|
|
1999
|
+
* the live branch head must begin with it, and that canonical object is
|
|
2000
|
+
* the identity compared everywhere after, never an argument;
|
|
2001
|
+
* - the issue still wants the work: a closed issue, a merged row or a merged
|
|
2002
|
+
* recorded PR is "resolved" and refuses; a recorded PR a human closed
|
|
2003
|
+
* without merging refuses as `pr-not-open`;
|
|
2004
|
+
* - no PR already represents that head: an open closer on the run's branch
|
|
2005
|
+
* at the recorded head is adopted (idempotent — the row gains the URL it
|
|
2006
|
+
* never recorded, so review/merge verbs can act on it), an open closer at
|
|
2007
|
+
* a different head or on another branch refuses, and a recorded PR that is
|
|
2008
|
+
* still open is that same recovery's answer.
|
|
2009
|
+
*
|
|
2010
|
+
* The PR describes itself from durable state only — no title/body arguments,
|
|
2011
|
+
* because nothing here may publish a headline a session did not already
|
|
2012
|
+
* settle. A definitively missing recorded PR (a corroborated 404 — the #779
|
|
2013
|
+
* guess) is replaced by the new one; an unreadable recorded PR, unreadable
|
|
2014
|
+
* branch head or unreadable closer list fails closed and stays retryable.
|
|
2015
|
+
*/
|
|
2016
|
+
async function prRecoverVerb(
|
|
2017
|
+
deps: VerbDeps,
|
|
2018
|
+
project: ProjectConfig,
|
|
2019
|
+
channel: VerbChannel,
|
|
2020
|
+
args: Record<string, unknown>,
|
|
2021
|
+
refuse: Refuse,
|
|
2022
|
+
allow: Allow,
|
|
2023
|
+
): Promise<Verdict> {
|
|
2024
|
+
const ref = issueRefFrom(String(args["issueUrl"]));
|
|
2025
|
+
if (ref === undefined) {
|
|
2026
|
+
return refuse(
|
|
2027
|
+
"malformed-argument",
|
|
2028
|
+
`refused: "${String(args["issueUrl"])}" is not a full issue URL. A bare number resolves against whatever ` +
|
|
2029
|
+
"repository the daemon happens to be in, which is how a recovered PR lands on the wrong project's issue.",
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
if (ref.repo !== project.tracker.repo) {
|
|
2033
|
+
return refuse(
|
|
2034
|
+
"malformed-argument",
|
|
2035
|
+
`refused: ${ref.repo} is not this project's tracker (${project.tracker.repo}).`,
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// The run is the newest attempt for the issue — the row that occupies the
|
|
2040
|
+
// issue's slot, and exactly the row `unblock`/`worker stop` name by issue
|
|
2041
|
+
// number. Recovery never re-implements dispatch's per-issue choice.
|
|
2042
|
+
const target = deps.store.latestRun(project.name, ref.issue);
|
|
2043
|
+
if (target === undefined) {
|
|
2044
|
+
return refuse(
|
|
2045
|
+
"recovery-no-run",
|
|
2046
|
+
`refused: ${project.name} has no recorded attempt for #${ref.issue}, so there is no run branch to recover a ` +
|
|
2047
|
+
"pull request for. Check the issue's status before trying again.",
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
const issue = target.issue;
|
|
2051
|
+
|
|
2052
|
+
if (LIVE_STATES.some((state) => state === target.state)) {
|
|
2053
|
+
return refuse(
|
|
2054
|
+
"recovery-run-live",
|
|
2055
|
+
`refused: run ${target.id} for #${issue} is ${target.state} — a worker is still live on it. ` +
|
|
2056
|
+
"Recovery opens the missing PR only for a settled run; wait for it to settle (or stop the worker) first.",
|
|
2057
|
+
issue,
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
if (target.state === "merged") {
|
|
2061
|
+
return refuse(
|
|
2062
|
+
"recovery-issue-resolved",
|
|
2063
|
+
`refused: run ${target.id} for #${issue} already merged — the work landed and there is no missing PR to recover.`,
|
|
2064
|
+
issue,
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
if (RECOVERABLE_RUN_STATES[target.state] !== true) {
|
|
2068
|
+
const guidance =
|
|
2069
|
+
target.state === "failed" || target.state === "killed"
|
|
2070
|
+
? "A failed or killed run's branch continues through `omp-conductor unblock` (continuation), or through a review revision once it owns a PR."
|
|
2071
|
+
: target.state === "stopped"
|
|
2072
|
+
? "The operator ended this run; recovery does not reopen stopped work."
|
|
2073
|
+
: target.state === "orphaned"
|
|
2074
|
+
? "Restart reconciliation owns orphaned runs."
|
|
2075
|
+
: "Answer the run's question and unblock the issue.";
|
|
2076
|
+
return refuse(
|
|
2077
|
+
"recovery-unsettled-state",
|
|
2078
|
+
`refused: run ${target.id} for #${issue} is ${target.state}, a state the settlement sweep does not own — a ` +
|
|
2079
|
+
`recovered PR would never be verified, reviewed or merged. ${guidance}`,
|
|
2080
|
+
issue,
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
2085
|
+
if (paused !== undefined) return paused;
|
|
2086
|
+
|
|
2087
|
+
const routed = Object.values(project.routing.repos).find((repo) => repo.name === target.repo);
|
|
2088
|
+
if (routed === undefined) {
|
|
2089
|
+
return refuse(
|
|
2090
|
+
"recovery-repo-unrouted",
|
|
2091
|
+
`refused: run ${target.id} was routed to repository ${target.repo}, which ${project.name} no longer routes ` +
|
|
2092
|
+
`(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}). ` +
|
|
2093
|
+
"A recovery PR cannot be created for a repository the daemon has no entry for; re-route the repo first.",
|
|
2094
|
+
issue,
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// The recorded head the preserved branch must still carry. Absent, the
|
|
2099
|
+
// recovery cannot prove it would publish this run's work rather than
|
|
2100
|
+
// whatever the branch tip happens to be — fail closed on the missing
|
|
2101
|
+
// record. Legacy rows recorded git's default abbreviation (`rev-parse
|
|
2102
|
+
// --short`, 7+ hex) instead of the full SHA; those stay eligible and are
|
|
2103
|
+
// canonicalized against the fixed routed branch below (#836).
|
|
2104
|
+
const recordedHead = target.headSha;
|
|
2105
|
+
if (recordedHead === undefined) {
|
|
2106
|
+
return refuse(
|
|
2107
|
+
"recovery-unrecorded-head",
|
|
2108
|
+
`refused: run ${target.id} for #${issue} records no head to verify its branch against. ` +
|
|
2109
|
+
"Recovery publishes only the head the run itself recorded.",
|
|
2110
|
+
issue,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
const isFullHead = FULL_HEAD_SHA.test(recordedHead);
|
|
2114
|
+
const isAbbreviatedHead = !isFullHead && ABBREVIATED_HEAD_SHA.test(recordedHead);
|
|
2115
|
+
if (!isFullHead && !isAbbreviatedHead) {
|
|
2116
|
+
return refuse(
|
|
2117
|
+
"recovery-unrecorded-head",
|
|
2118
|
+
`refused: run ${target.id} for #${issue} records ${JSON.stringify(recordedHead)}, which is neither a full ` +
|
|
2119
|
+
"40-hex SHA nor an unambiguous legacy abbreviation of one (7–39 hex). Recovery publishes only the head " +
|
|
2120
|
+
"the run itself recorded.",
|
|
2121
|
+
issue,
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Assertions, never selectors — the mirror of `conductor_pr_create`'s
|
|
2126
|
+
// head/base claims. A disagreement means the caller is pointing somewhere
|
|
2127
|
+
// the run row does not own; the daemon derives everything from the row.
|
|
2128
|
+
const claimedBranch = args["branch"];
|
|
2129
|
+
if (typeof claimedBranch === "string" && claimedBranch !== target.branch) {
|
|
2130
|
+
return refuse(
|
|
2131
|
+
"ref-not-run-branch",
|
|
2132
|
+
`refused: run ${target.id}'s recorded branch is ${target.branch}, and you named "${claimedBranch}". ` +
|
|
2133
|
+
"Recovery derives the head branch from the run; it cannot be pointed at another branch.",
|
|
2134
|
+
issue,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
const claimedBase = args["base"];
|
|
2138
|
+
if (typeof claimedBase === "string" && claimedBase !== routed.defaultBranch) {
|
|
2139
|
+
return refuse(
|
|
2140
|
+
"base-not-default-branch",
|
|
2141
|
+
`refused: ${routed.name} targets ${routed.defaultBranch}, and you named "${claimedBase}". ` +
|
|
2142
|
+
"The base is the configured defaultBranch, not an argument.",
|
|
2143
|
+
issue,
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
const claimedHead = args["headSha"];
|
|
2147
|
+
if (typeof claimedHead === "string" && claimedHead.toLowerCase() !== recordedHead.toLowerCase()) {
|
|
2148
|
+
return refuse(
|
|
2149
|
+
"recovery-head-mismatch",
|
|
2150
|
+
`refused: run ${target.id} recorded head ${recordedHead}, and you asserted "${claimedHead}". ` +
|
|
2151
|
+
"The exact recorded head is the daemon's, not a selector.",
|
|
2152
|
+
issue,
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Closed issues are resolved issues: a merge closed it, or a human closed it
|
|
2157
|
+
// without merging. Either way it does not want a recovered PR — and an
|
|
2158
|
+
// unreadable state is not a green light: "could not tell" must never open a
|
|
2159
|
+
// PR beside (or for) an issue that may already be closed, so it fails
|
|
2160
|
+
// closed and stays retryable like every sibling read in this verb.
|
|
2161
|
+
let issueState: IssueState | undefined;
|
|
2162
|
+
try {
|
|
2163
|
+
issueState = await deps.tracker.issueState(issue);
|
|
2164
|
+
} catch (err) {
|
|
2165
|
+
issueState = undefined;
|
|
2166
|
+
deps.log(`conductor_pr_recover could not read #${issue} state: ${err instanceof Error ? err.message : String(err)}`);
|
|
2167
|
+
}
|
|
2168
|
+
if (issueState === undefined) {
|
|
2169
|
+
return refuse(
|
|
2170
|
+
"issue-state-unreadable",
|
|
2171
|
+
`refused: the tracker could not say whether #${issue} is open. An unreadable state is not a green light — ` +
|
|
2172
|
+
"retry before recovering, so a recovered PR is never opened beside (or for) a closed issue.",
|
|
2173
|
+
issue,
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
if (issueState === "closed") {
|
|
2177
|
+
return refuse(
|
|
2178
|
+
"recovery-issue-resolved",
|
|
2179
|
+
`refused: #${issue} is closed. The work already merged or a human declined it; recovery does not reopen either.`,
|
|
2180
|
+
issue,
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// The branch/head verification: the routed repository's branch, read live,
|
|
2185
|
+
// must still sit at the exact head this run recorded. This is the proof the
|
|
2186
|
+
// PR to be created publishes verified work and not something that replaced
|
|
2187
|
+
// it. An unreadable branch stays retryable — that is "could not tell", never
|
|
2188
|
+
// a green light.
|
|
2189
|
+
const identity = repoSlugFor(routed);
|
|
2190
|
+
let liveBranchHead: string | undefined;
|
|
2191
|
+
try {
|
|
2192
|
+
liveBranchHead = await deps.tracker.branchHead(identity, target.branch);
|
|
2193
|
+
} catch (err) {
|
|
2194
|
+
liveBranchHead = undefined;
|
|
2195
|
+
deps.log(
|
|
2196
|
+
`conductor_pr_recover could not read ${identity}/${target.branch} head: ${err instanceof Error ? err.message : String(err)}`,
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
if (liveBranchHead === undefined) {
|
|
2200
|
+
return refuse(
|
|
2201
|
+
"head-unresolvable",
|
|
2202
|
+
`refused: the live head of ${identity}/${target.branch} could not be read. That is a transient or unknown ` +
|
|
2203
|
+
"state, not a green light — retry before recovering, and check the branch still exists.",
|
|
1757
2204
|
issue,
|
|
1758
2205
|
);
|
|
1759
2206
|
}
|
|
1760
2207
|
|
|
2208
|
+
// The canonical object, decided from the fixed routed branch only (#836). A
|
|
2209
|
+
// full 40-hex record keeps its exact-identity behavior: the branch must
|
|
2210
|
+
// still sit at that object. A legacy abbreviated record can never be used
|
|
2211
|
+
// as an identity on its own — the live branch head must literally begin
|
|
2212
|
+
// with the abbreviation, and that full head becomes the identity every
|
|
2213
|
+
// later check compares against (PR adoption, no-duplicate proof, the PR's
|
|
2214
|
+
// own description). Nothing resolves the abbreviation anywhere else, and
|
|
2215
|
+
// never from caller input: `headSha` remains an assertion of the recorded
|
|
2216
|
+
// value, so no argument can appoint a different object.
|
|
2217
|
+
const canonicalHead: string = isFullHead ? recordedHead : liveBranchHead;
|
|
2218
|
+
if (isAbbreviatedHead) {
|
|
2219
|
+
if (!FULL_HEAD_SHA.test(liveBranchHead)) {
|
|
2220
|
+
return refuse(
|
|
2221
|
+
"head-unresolvable",
|
|
2222
|
+
`refused: the live head of ${identity}/${target.branch} is ${liveBranchHead}, not a full commit SHA, so the ` +
|
|
2223
|
+
`recorded abbreviation ${recordedHead} cannot be canonicalized against it. That is an unknown state, not a ` +
|
|
2224
|
+
"green light — retry before recovering.",
|
|
2225
|
+
issue,
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
if (!liveBranchHead.toLowerCase().startsWith(recordedHead.toLowerCase())) {
|
|
2229
|
+
return refuse(
|
|
2230
|
+
"recovery-head-mismatch",
|
|
2231
|
+
`refused: ${identity}/${target.branch} now points at ${liveBranchHead}, which does not begin with the ` +
|
|
2232
|
+
`recorded run head ${recordedHead}. A legacy abbreviated head canonicalizes only against a branch that ` +
|
|
2233
|
+
"still carries it; the branch was replaced, force-updated or continued elsewhere — a PR created now would " +
|
|
2234
|
+
"publish work the daemon did not verify at that head. Do not force; reconcile what the branch should be first.",
|
|
2235
|
+
issue,
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
} else if (liveBranchHead.toLowerCase() !== recordedHead.toLowerCase()) {
|
|
2239
|
+
return refuse(
|
|
2240
|
+
"recovery-head-mismatch",
|
|
2241
|
+
`refused: ${identity}/${target.branch} now points at ${liveBranchHead}, not the recorded run head ${recordedHead}. ` +
|
|
2242
|
+
"The branch was replaced, force-updated or continued elsewhere — a PR created now would publish work the daemon " +
|
|
2243
|
+
"did not verify at that head. Do not force; reconcile what the branch should be first.",
|
|
2244
|
+
issue,
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// The no-duplicate-proof: any OPEN PR that already closes the issue. The
|
|
2249
|
+
// run's own branch at the recorded head is the run's PR (whoever opened it —
|
|
2250
|
+
// the operator's manual recovery is PR #805 for run #777) and is adopted,
|
|
2251
|
+
// idempotently; the same branch at a different head, or another branch with
|
|
2252
|
+
// the issue, each refuse rather than duplicate.
|
|
2253
|
+
let closers: OpenCloser[];
|
|
2254
|
+
try {
|
|
2255
|
+
closers = await deps.tracker.openClosersFor(issue);
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
2258
|
+
return refuse(
|
|
2259
|
+
"open-pr-lookup-error",
|
|
2260
|
+
`refused: the tracker could not say whether #${issue} already has an open pull request (${why}). ` +
|
|
2261
|
+
"Refusing rather than opening a second one.",
|
|
2262
|
+
issue,
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
const sameBranch = closers.find(
|
|
2266
|
+
(closer) => closer.headRefName !== "" && closer.headRefName === target.branch && closer.repo === identity,
|
|
2267
|
+
);
|
|
2268
|
+
if (sameBranch !== undefined) {
|
|
2269
|
+
let live: string | undefined;
|
|
2270
|
+
try {
|
|
2271
|
+
live = await deps.tracker.prHead(sameBranch.url);
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
live = undefined;
|
|
2274
|
+
deps.log(
|
|
2275
|
+
`conductor_pr_recover could not read ${sameBranch.url} head: ${err instanceof Error ? err.message : String(err)}`,
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
if (live === undefined) {
|
|
2279
|
+
return refuse(
|
|
2280
|
+
"head-unresolvable",
|
|
2281
|
+
`refused: ${sameBranch.url} is open on ${target.branch}, but its head could not be read. ` +
|
|
2282
|
+
"Retry before deciding whether to adopt it or treat it as a conflict.",
|
|
2283
|
+
issue,
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
if (live.toLowerCase() === canonicalHead.toLowerCase()) {
|
|
2287
|
+
const already = deps.store.getRun(target.id)?.prUrl === sameBranch.url;
|
|
2288
|
+
if (!already) deps.store.updateRun(target.id, { prUrl: sameBranch.url });
|
|
2289
|
+
return allow(
|
|
2290
|
+
`recovered ${sameBranch.url} — an open pull request already exists at run head ${canonicalHead} ` +
|
|
2291
|
+
`(${target.branch}); ${
|
|
2292
|
+
already
|
|
2293
|
+
? "it is already this run's recorded PR"
|
|
2294
|
+
: `recorded it on run ${target.id} so review and merge verbs can act on it`
|
|
2295
|
+
}.`,
|
|
2296
|
+
undefined,
|
|
2297
|
+
issue,
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
return refuse(
|
|
2301
|
+
"recovery-head-mismatch",
|
|
2302
|
+
`refused: ${sameBranch.url} is open on ${target.branch} but at ${live}, not the recorded run head ${canonicalHead}. ` +
|
|
2303
|
+
"That open PR superseded the run's recorded head; a second PR would duplicate it.",
|
|
2304
|
+
issue,
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
if (closers.length > 0) {
|
|
2308
|
+
const other = closers[0]!;
|
|
2309
|
+
return refuse(
|
|
2310
|
+
"open-pr-exists",
|
|
2311
|
+
`refused: ${other.url} is already open and already closes #${issue} (head ${other.headRefName || "unknown"} in ${
|
|
2312
|
+
other.repo || "unknown"
|
|
2313
|
+
}). Recovery opens a PR only for the run's own preserved branch, and this issue already has open work.`,
|
|
2314
|
+
issue,
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// The durable PR field is normalized and validated before any recorded-PR
|
|
2319
|
+
// lookup (#866). A real recorded PR is a pull-request URL the tracker can
|
|
2320
|
+
// address — the `prUrlParts` shape, the same guard the tracker's own
|
|
2321
|
+
// `prState` applies. The `pending` settlement sentinel is not a URL: it
|
|
2322
|
+
// records the run believed a PR was in flight but never observed one, so it
|
|
2323
|
+
// is normalized to absence and recovery proceeds like a run with no
|
|
2324
|
+
// recorded PR. Any other non-URL value is corruption — there is nothing for
|
|
2325
|
+
// the tracker to read, and had it been queried the fail-closed `undefined`
|
|
2326
|
+
// would read as an unreadable-but-real PR that can never clear — so it
|
|
2327
|
+
// refuses by name instead of hanging the run on a retry.
|
|
2328
|
+
const recordedPrUrl = target.prUrl;
|
|
2329
|
+
const pendingSentinel = recordedPrUrl === PR_PENDING_SENTINEL;
|
|
2330
|
+
if (recordedPrUrl !== undefined && !pendingSentinel && prUrlParts(recordedPrUrl) === undefined) {
|
|
2331
|
+
return refuse(
|
|
2332
|
+
"recorded-pr-corrupt",
|
|
2333
|
+
`refused: run ${target.id} for #${issue} records prUrl ${JSON.stringify(recordedPrUrl)}, which is not a pull ` +
|
|
2334
|
+
"request URL. A non-URL durable prUrl is a corrupt run record, not an unreadable PR — recovery will not " +
|
|
2335
|
+
"guess a GitHub pull request from it. Clear or correct the run's prUrl, then retry.",
|
|
2336
|
+
issue,
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (!pendingSentinel && target.prUrl !== undefined) {
|
|
2341
|
+
// The run believes it has a PR. Before creating one, the recorded URL is
|
|
2342
|
+
// decided: still open → it is the recovery's answer (idempotent); merged →
|
|
2343
|
+
// the work landed; closed → a human declined it; definitively missing →
|
|
2344
|
+
// the #779 guess, replaced by a real PR below.
|
|
2345
|
+
let recorded: PrState | "missing" | undefined;
|
|
2346
|
+
try {
|
|
2347
|
+
recorded = await deps.tracker.prState(target.prUrl);
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
recorded = err instanceof GhPrMissingError ? "missing" : undefined;
|
|
2350
|
+
if (recorded !== "missing") {
|
|
2351
|
+
deps.log(
|
|
2352
|
+
`conductor_pr_recover could not read recorded PR ${target.prUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
if (recorded === "open") {
|
|
2357
|
+
// An open recorded PR is the recovery's answer only when it actually
|
|
2358
|
+
// represents the recorded head — otherwise it is some other PR the row
|
|
2359
|
+
// once believed in, and answering "already open, nothing to create"
|
|
2360
|
+
// would leave the run's own head unpublished.
|
|
2361
|
+
let recordedLive: string | undefined;
|
|
2362
|
+
try {
|
|
2363
|
+
recordedLive = await deps.tracker.prHead(target.prUrl);
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
recordedLive = undefined;
|
|
2366
|
+
deps.log(
|
|
2367
|
+
`conductor_pr_recover could not read ${target.prUrl} head: ${err instanceof Error ? err.message : String(err)}`,
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
if (recordedLive === undefined) {
|
|
2371
|
+
return refuse(
|
|
2372
|
+
"head-unresolvable",
|
|
2373
|
+
`refused: ${target.prUrl} is open, but its head could not be read. ` +
|
|
2374
|
+
"Retry before deciding whether it is this run's PR or another one.",
|
|
2375
|
+
issue,
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
if (recordedLive.toLowerCase() === canonicalHead.toLowerCase()) {
|
|
2379
|
+
return allow(
|
|
2380
|
+
`recovered ${target.prUrl} — this run's recorded pull request is already open at the recorded head; nothing to create.`,
|
|
2381
|
+
undefined,
|
|
2382
|
+
issue,
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
return refuse(
|
|
2386
|
+
"recovery-head-mismatch",
|
|
2387
|
+
`refused: the run records ${target.prUrl}, which is open at ${recordedLive}, not the recorded run head ${canonicalHead}. ` +
|
|
2388
|
+
"That PR is not this run's head; recovery cannot claim it. Match the PR to the head by hand, or close it and recover afresh.",
|
|
2389
|
+
issue,
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
if (recorded === "merged") {
|
|
2393
|
+
return refuse(
|
|
2394
|
+
"recovery-issue-resolved",
|
|
2395
|
+
`refused: the run's recorded PR ${target.prUrl} already merged — the work landed; there is no missing PR to recover.`,
|
|
2396
|
+
issue,
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
if (recorded === "closed") {
|
|
2400
|
+
return refuse(
|
|
2401
|
+
"pr-not-open",
|
|
2402
|
+
`refused: the run's recorded PR ${target.prUrl} was closed without merging. ` +
|
|
2403
|
+
"Recovery does not resurrect a PR a human declined; reopen it by hand if that was a mistake.",
|
|
2404
|
+
issue,
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
if (recorded === undefined) {
|
|
2408
|
+
return refuse(
|
|
2409
|
+
"head-unresolvable",
|
|
2410
|
+
`refused: the run records PR ${target.prUrl}, but its state could not be read. ` +
|
|
2411
|
+
"Retry rather than creating a second PR next to one that may exist.",
|
|
2412
|
+
issue,
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
// `missing`: the recorded URL provably never existed (a #779 guess). Fall
|
|
2416
|
+
// through and replace it with a real PR at the verified head.
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// The PR's own voice comes from durable state only: the issue title and the
|
|
2420
|
+
// run's settlement report. The closing keyword must name the TRACKER
|
|
2421
|
+
// repository — the issue lives there, not in the routed code repo, and
|
|
2422
|
+
// `openClosersFor` asks the tracker's GraphQL `closedByPullRequestsReferences`
|
|
2423
|
+
// whether an OPEN PR already closes it. A keyword aimed at the code repo
|
|
2424
|
+
// would close an unrelated same-numbered issue there, leave the tracker
|
|
2425
|
+
// issue open, and hide this PR from the duplicate guard on the next call.
|
|
2426
|
+
let issueTitle: string | undefined;
|
|
2427
|
+
try {
|
|
2428
|
+
issueTitle = (await deps.tracker.getIssue(issue))?.title;
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
issueTitle = undefined;
|
|
2431
|
+
deps.log(`conductor_pr_recover could not read #${issue} title: ${err instanceof Error ? err.message : String(err)}`);
|
|
2432
|
+
}
|
|
2433
|
+
const body = [
|
|
2434
|
+
`Closes ${project.tracker.repo}#${issue}`,
|
|
2435
|
+
"",
|
|
2436
|
+
`Recovered pull request for run ${target.id} (attempt ${target.attempt}).`,
|
|
2437
|
+
`The run ended ${target.state} with no pull request; this PR opens its preserved branch ${target.branch} at the ` +
|
|
2438
|
+
`exact recorded head ${canonicalHead} through the orchestrator's conductor_pr_recover verb.`,
|
|
2439
|
+
...(target.report === undefined ? [] : ["", "Worker report:", "", target.report]),
|
|
2440
|
+
].join("\n");
|
|
2441
|
+
// The worktree of a settled run is long removed; `openRunPr` resolves
|
|
2442
|
+
// everything from the remote identities (repo slug, branch, base) and never
|
|
2443
|
+
// opens the path — it is carried only to satisfy the shared action surface.
|
|
2444
|
+
const outcome = await deps.actions.createPr(
|
|
2445
|
+
{ repo: routed, runRepoPath: target.worktree, branch: target.branch },
|
|
2446
|
+
{
|
|
2447
|
+
title: issueTitle ?? `recover ${target.branch} (${project.tracker.repo} #${issue})`,
|
|
2448
|
+
body,
|
|
2449
|
+
base: routed.defaultBranch,
|
|
2450
|
+
},
|
|
2451
|
+
);
|
|
2452
|
+
if (!outcome.ok) {
|
|
2453
|
+
return refuse("action-failed", `refused: gh could not open the recovered pull request:\n${outcome.stderr}`, issue);
|
|
2454
|
+
}
|
|
2455
|
+
deps.store.updateRun(target.id, { prUrl: outcome.url });
|
|
1761
2456
|
return allow(
|
|
1762
|
-
`
|
|
1763
|
-
|
|
2457
|
+
`opened ${outcome.url} for ${target.branch} at ${canonicalHead} (${identity} → ${routed.defaultBranch}), ` +
|
|
2458
|
+
"recorded on the run so review/merge settlement resumes.",
|
|
1764
2459
|
undefined,
|
|
1765
2460
|
issue,
|
|
1766
2461
|
);
|
|
@@ -1799,6 +2494,14 @@ export class VerbSocketRefusal extends Error {
|
|
|
1799
2494
|
export interface ListenOptions {
|
|
1800
2495
|
peerReader?: PeerReader;
|
|
1801
2496
|
daemonUid?: number;
|
|
2497
|
+
/**
|
|
2498
|
+
* The identity the channel is secured to — the worker account for a worker
|
|
2499
|
+
* run channel (#798). When set, the bound socket is chowned to that uid/gid
|
|
2500
|
+
* (the worker child, and only it, must be able to connect) and the peer
|
|
2501
|
+
* verdict expects that uid on the wire. Absent, the channel stays
|
|
2502
|
+
* daemon-owned and daemon-uid as today (orchestrator, tests).
|
|
2503
|
+
*/
|
|
2504
|
+
channelOwner?: { uid: number; gid: number };
|
|
1802
2505
|
/** Injected in tests; the real one chowns, which needs privilege. */
|
|
1803
2506
|
secure?: typeof secureBoundSocket;
|
|
1804
2507
|
}
|
|
@@ -1836,8 +2539,10 @@ export async function listenVerbChannel(
|
|
|
1836
2539
|
// a connection arrives, not the one that existed when the server was created.
|
|
1837
2540
|
let boundPid: number | undefined;
|
|
1838
2541
|
|
|
2542
|
+
const expectedUid = opts.channelOwner?.uid ?? process.getuid?.() ?? 0;
|
|
2543
|
+
|
|
1839
2544
|
const server: Server = createServer((socket) => {
|
|
1840
|
-
handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
|
|
2545
|
+
handleConnection(deps, channel, socket, opts.peerReader, () => boundPid, expectedUid);
|
|
1841
2546
|
});
|
|
1842
2547
|
server.on("error", (err) => {
|
|
1843
2548
|
deps.log(`verb socket ${channel.path} errored: ${err.message}`);
|
|
@@ -1851,7 +2556,10 @@ export async function listenVerbChannel(
|
|
|
1851
2556
|
});
|
|
1852
2557
|
});
|
|
1853
2558
|
|
|
1854
|
-
|
|
2559
|
+
// A worker-run channel belongs to the worker: the socket is chowned to it so
|
|
2560
|
+
// the session child can connect, and the ownership line in the startup banner
|
|
2561
|
+
// says so rather than guessing.
|
|
2562
|
+
const secure = opts.secure ?? ((path: string) => secureBoundSocket(path, { owner: opts.channelOwner }));
|
|
1855
2563
|
const ownership = secure(channel.path);
|
|
1856
2564
|
|
|
1857
2565
|
return {
|
|
@@ -1877,6 +2585,7 @@ function handleConnection(
|
|
|
1877
2585
|
socket: Socket,
|
|
1878
2586
|
peerReader: PeerReader | undefined,
|
|
1879
2587
|
boundPid: () => number | undefined,
|
|
2588
|
+
expectedUid: number,
|
|
1880
2589
|
): void {
|
|
1881
2590
|
const fd = socketFd(socket);
|
|
1882
2591
|
const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
|
|
@@ -1893,12 +2602,12 @@ function handleConnection(
|
|
|
1893
2602
|
? {
|
|
1894
2603
|
ok: false,
|
|
1895
2604
|
peerUid: peer?.uid ?? -1,
|
|
1896
|
-
expectedUid
|
|
2605
|
+
expectedUid,
|
|
1897
2606
|
detail:
|
|
1898
2607
|
"this channel has not been bound to a process yet, so the caller cannot be identified — " +
|
|
1899
|
-
"
|
|
2608
|
+
"this socket is 0600 and its owner is the only uid that can reach it",
|
|
1900
2609
|
}
|
|
1901
|
-
: peerVerdict({ uid:
|
|
2610
|
+
: peerVerdict({ uid: expectedUid, pid: expectedPid }, peer);
|
|
1902
2611
|
if (!verdict.ok) {
|
|
1903
2612
|
// Not a client error, and deliberately not answered: a caller who is not
|
|
1904
2613
|
// who the socket was allocated to gets no reply to calibrate against.
|