@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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.
Files changed (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Focused merge-stage conflict repair: context validation and the supervised
3
+ * post-push wait (BAPI-1311).
4
+ *
5
+ * Before BAPI-1311 a `merging`-gate `rebase` re-ran the whole `/implement-ticket`
6
+ * recipe, and its worker also owned the post-push CI wait. A check that was still
7
+ * running when that wait ended turned a completed repair into a failed attempt,
8
+ * which the gate then answered with ANOTHER full repair.
9
+ *
10
+ * This module holds the two executor halves of the replacement:
11
+ *
12
+ * 1. {@link validateFocusedRebaseContext} — the narrow boundary validator for the
13
+ * server's versioned `payload.focused_rebase` context. It runs at claimed-job
14
+ * entry, before any worktree effect. A malformed context is REFUSED by name; it
15
+ * is never reinterpreted as a legacy rebase and never downgraded to the full
16
+ * recipe.
17
+ * 2. {@link waitForFocusedRebase} — after the worker exited cleanly and
18
+ * finalization PROVED its push landed, wait for the evidence the server's
19
+ * completion verifier will read: the same open pull request, at the exact
20
+ * pushed head, on the expected head branch and base, mergeable, with every
21
+ * effective required check green at that head. Every read and sleep is bounded
22
+ * by the claim's remaining absolute deadline and abort signal.
23
+ *
24
+ * The wait READS; it never acts. It launches no worker, enqueues nothing, resets
25
+ * nothing, and pushes nothing. `ready` is permission to submit `/complete` once,
26
+ * not a gate verdict: the server re-reads the provider and may still refuse (R14
27
+ * rule 3).
28
+ */
29
+ import { ExecutorNamedError, FocusedRebaseContext } from "./job-errors.js";
30
+ import { allRequiredChecksGreen, anyRequiredCheckFailed, ProviderChecksUnobservable, } from "./provider-checks.js";
31
+ import { FOCUSED_REBASE_CAPABILITY, FOCUSED_REBASE_CONTEXT_VERSION, } from "./types.js";
32
+ /** The job type the focused context is valid on. */
33
+ export const FOCUSED_REBASE_JOB_TYPE = "rebase";
34
+ /** The payload key the server writes the context under. */
35
+ export const FOCUSED_REBASE_PAYLOAD_KEY = "focused_rebase";
36
+ /**
37
+ * Bounds shared with the server (`focused_rebase_dispatch.py`). Over-bound input
38
+ * is refused, never truncated: a truncated identity is a different identity.
39
+ */
40
+ export const FOCUSED_REBASE_IDENTITY_MAX_CHARS = 255;
41
+ export const FOCUSED_REBASE_MAX_REQUIRED_CHECKS = 64;
42
+ export const FOCUSED_REBASE_CHECK_NAME_MAX_CHARS = 200;
43
+ /**
44
+ * Time reserved at the end of the job for the one terminal report, mirroring the
45
+ * overlap lane's `CI_WAIT_MARGIN_MS` precedent. Clamped to half the remaining
46
+ * time, so a repair that finishes close to its deadline still gets one bounded
47
+ * look at the evidence instead of none.
48
+ */
49
+ export const FOCUSED_REBASE_REPORT_MARGIN_MS = 90_000;
50
+ /** Poll cadence while evidence is pending. */
51
+ export const FOCUSED_REBASE_POLL_INTERVAL_MS = 30_000;
52
+ /** Cap on any single provider read, before the deadline clamp. */
53
+ export const FOCUSED_REBASE_READ_MAX_MS = 60_000;
54
+ /** Does this claimed job carry a focused context at all? (Presence, not validity.) */
55
+ export function hasFocusedRebaseContext(job) {
56
+ const payload = job.payload;
57
+ if (!payload || typeof payload !== "object")
58
+ return false;
59
+ const raw = payload[FOCUSED_REBASE_PAYLOAD_KEY];
60
+ return raw !== undefined && raw !== null;
61
+ }
62
+ /** The named refusal the runner reports for an invalid focused context. */
63
+ export function focusedRebaseContextError(job, reason) {
64
+ return new ExecutorNamedError(FocusedRebaseContext, `rebase job ${job.id} refused before worker effects (reason=${reason})`);
65
+ }
66
+ function identity(value) {
67
+ if (typeof value !== "string")
68
+ return null;
69
+ const text = value.trim();
70
+ if (text.length === 0 || text.length > FOCUSED_REBASE_IDENTITY_MAX_CHARS)
71
+ return null;
72
+ if (/[\r\n\0]/.test(text))
73
+ return null;
74
+ return text;
75
+ }
76
+ function positiveInt(value) {
77
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
78
+ }
79
+ function checkList(value) {
80
+ if (!Array.isArray(value) || value.length > FOCUSED_REBASE_MAX_REQUIRED_CHECKS)
81
+ return null;
82
+ const out = [];
83
+ for (const item of value) {
84
+ if (typeof item !== "string" ||
85
+ item.trim().length === 0 ||
86
+ item.length > FOCUSED_REBASE_CHECK_NAME_MAX_CHARS ||
87
+ /[\n\0]/.test(item)) {
88
+ return null;
89
+ }
90
+ out.push(item);
91
+ }
92
+ return out;
93
+ }
94
+ const CHECK_SOURCES = ["policy", "plan", "none"];
95
+ /**
96
+ * Validate `payload.focused_rebase` against the CLAIMED job, before any effect.
97
+ *
98
+ * Every identity the context binds must equal what the claim itself carries:
99
+ * run, ticket, repository, and branch against the claim's top-level fields; the
100
+ * attempt, pull request, base, and required checks against the payload fields
101
+ * the server wrote beside the context (the completion verifier reads the payload
102
+ * copies, so a disagreement would make the executor wait on one set while the
103
+ * server verifies another). The claim must also REQUIRE the focused capability,
104
+ * which is what kept an executor unable to honor the context from claiming it.
105
+ *
106
+ * Nothing is coerced and nothing falls back: a conflicting value is a refusal,
107
+ * not a choice between the two.
108
+ */
109
+ export function validateFocusedRebaseContext(job) {
110
+ const fail = (reason) => ({ ok: false, reason });
111
+ if (job.job_type !== FOCUSED_REBASE_JOB_TYPE)
112
+ return fail("not_rebase_job");
113
+ const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
114
+ const raw = payload[FOCUSED_REBASE_PAYLOAD_KEY];
115
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
116
+ return fail("context_not_object");
117
+ const ctx = raw;
118
+ if (ctx.version !== FOCUSED_REBASE_CONTEXT_VERSION)
119
+ return fail("version_unsupported");
120
+ const required = Array.isArray(job.required_capabilities) ? job.required_capabilities : [];
121
+ if (!required.includes(FOCUSED_REBASE_CAPABILITY))
122
+ return fail("capability_not_required");
123
+ const prompt = payload.prompt;
124
+ if (typeof prompt !== "string" || prompt.trim().length === 0)
125
+ return fail("prompt_missing");
126
+ const epicRunId = identity(ctx.epic_run_id);
127
+ const dedupKey = identity(ctx.dedup_key);
128
+ const ticketKey = identity(ctx.ticket_key);
129
+ const repoName = identity(ctx.repo_name);
130
+ const workBranch = identity(ctx.work_branch);
131
+ const attempt = positiveInt(ctx.attempt);
132
+ if (epicRunId === null ||
133
+ dedupKey === null ||
134
+ ticketKey === null ||
135
+ repoName === null ||
136
+ workBranch === null ||
137
+ attempt === null) {
138
+ return fail("identity_malformed");
139
+ }
140
+ if (epicRunId !== (typeof job.epic_run_id === "string" ? job.epic_run_id.trim() : null)) {
141
+ return fail("run_mismatch");
142
+ }
143
+ if (ticketKey !== (typeof job.ticket_key === "string" ? job.ticket_key.trim() : null)) {
144
+ return fail("ticket_mismatch");
145
+ }
146
+ if (repoName !== job.repo_name.trim())
147
+ return fail("repository_mismatch");
148
+ if (workBranch !== (typeof job.expected_branch === "string" ? job.expected_branch.trim() : null)) {
149
+ return fail("branch_mismatch");
150
+ }
151
+ if (!dedupKey.includes(ticketKey) || attempt !== payload.attempt) {
152
+ return fail("job_identity_mismatch");
153
+ }
154
+ const prNumber = positiveInt(ctx.pr_number);
155
+ if (prNumber === null)
156
+ return fail("pr_malformed");
157
+ if (prNumber !== payload.pr_number)
158
+ return fail("pr_mismatch");
159
+ let baseBranch = null;
160
+ if (ctx.base_branch !== null) {
161
+ baseBranch = identity(ctx.base_branch);
162
+ if (baseBranch === null)
163
+ return fail("identity_malformed");
164
+ }
165
+ const payloadBase = typeof payload.base_branch === "string" ? payload.base_branch.trim() : null;
166
+ if (baseBranch !== payloadBase)
167
+ return fail("base_mismatch");
168
+ const checks = checkList(ctx.required_checks);
169
+ const source = ctx.required_checks_source;
170
+ if (checks === null ||
171
+ typeof source !== "string" ||
172
+ !CHECK_SOURCES.includes(source) ||
173
+ (source === "none" && checks.length > 0)) {
174
+ return fail("required_checks_malformed");
175
+ }
176
+ const payloadChecks = payload.required_checks;
177
+ if (!Array.isArray(payloadChecks) ||
178
+ payloadChecks.length !== checks.length ||
179
+ !checks.every((name, i) => payloadChecks[i] === name)) {
180
+ return fail("required_checks_mismatch");
181
+ }
182
+ return {
183
+ ok: true,
184
+ context: {
185
+ version: FOCUSED_REBASE_CONTEXT_VERSION,
186
+ epicRunId,
187
+ dedupKey,
188
+ attempt,
189
+ ticketKey,
190
+ repoName,
191
+ workBranch,
192
+ prNumber,
193
+ baseBranch,
194
+ requiredChecks: checks,
195
+ requiredChecksSource: source,
196
+ prompt,
197
+ },
198
+ };
199
+ }
200
+ /** A promise that rejects when `signal` aborts or `ms` elapses, whichever first. */
201
+ function boundedRace(work, ms, signal) {
202
+ if (signal.aborted)
203
+ return Promise.resolve("aborted");
204
+ return new Promise((resolve) => {
205
+ let settled = false;
206
+ const finish = (value) => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ clearTimeout(timer);
211
+ signal.removeEventListener("abort", onAbort);
212
+ resolve(value);
213
+ };
214
+ const onAbort = () => finish("aborted");
215
+ const timer = setTimeout(() => finish("timeout"), Math.max(0, ms));
216
+ signal.addEventListener("abort", onAbort, { once: true });
217
+ work.then((value) => finish(value), (err) => {
218
+ if (err instanceof ProviderChecksUnobservable) {
219
+ finish(err);
220
+ return;
221
+ }
222
+ finish("timeout");
223
+ });
224
+ });
225
+ }
226
+ /**
227
+ * Wait for the focused repair's current-head evidence under the original claim
228
+ * deadline. Never throws for a provider problem; every read failure is PENDING.
229
+ *
230
+ * `ready` requires, in one iteration: the SAME open pull request (by number), at
231
+ * the exact pushed head, on the bound work branch, targeting the expected base,
232
+ * reading `MERGEABLE`, and every effective required check green AT THAT HEAD. An
233
+ * explicitly empty server-derived check set is satisfied vacuously, but only once
234
+ * every pull-request condition holds.
235
+ *
236
+ * Proven failures stop at once: a required check concluded red, the pull request
237
+ * closed or reads `CONFLICTING`, or its number, head, head branch, or base moved.
238
+ * Running or missing checks, `UNKNOWN` mergeability, and unavailable reads stay
239
+ * pending while time remains. When the deadline (less the report margin) arrives
240
+ * first, the outcome is an UNVERIFIED stop naming what was still missing — never
241
+ * a red-CI claim.
242
+ */
243
+ export async function waitForFocusedRebase(input) {
244
+ const started = input.now();
245
+ const elapsed = () => Math.max(0, input.now() - started);
246
+ const remainingAtStart = Math.max(0, input.deadlineMs - started);
247
+ const margin = Math.min(input.reportMarginMs ?? FOCUSED_REBASE_REPORT_MARGIN_MS, Math.floor(remainingAtStart / 2));
248
+ const waitUntil = input.deadlineMs - margin;
249
+ const budget = () => Math.max(0, waitUntil - input.now());
250
+ const readMax = input.readMaxMs ?? FOCUSED_REBASE_READ_MAX_MS;
251
+ const interval = input.pollIntervalMs ?? FOCUSED_REBASE_POLL_INTERVAL_MS;
252
+ const pushed = input.pushedHeadSha.trim().toLowerCase();
253
+ const ctx = input.context;
254
+ const ids = `job=${input.jobId} ticket=${ctx.ticketKey} pr=#${ctx.prNumber} branch=${ctx.workBranch} ` +
255
+ `head=${pushed.slice(0, 12)} base=${input.expectedBaseBranch}`;
256
+ const secs = () => `${Math.round(elapsed() / 1000)}s`;
257
+ let lastPending = null;
258
+ const observe = async () => {
259
+ const prRead = await boundedRace(input.readPullRequest(Math.min(readMax, budget())), Math.min(readMax, budget()), input.signal);
260
+ if (prRead === "aborted" || prRead === "timeout") {
261
+ return prRead === "aborted" ? "aborted" : { kind: "pending", reason: "pr_unavailable" };
262
+ }
263
+ if (prRead instanceof ProviderChecksUnobservable)
264
+ return { kind: "pending", reason: "pr_unavailable" };
265
+ const pr = prRead;
266
+ if (!pr.available)
267
+ return { kind: "pending", reason: "pr_unavailable" };
268
+ if (pr.number !== ctx.prNumber)
269
+ return { kind: "stopped", reason: "pr_identity_changed" };
270
+ if (pr.state === null)
271
+ return { kind: "pending", reason: "pr_unavailable" };
272
+ if (pr.state !== "OPEN")
273
+ return { kind: "stopped", reason: "pr_closed" };
274
+ if (pr.headRefOid !== pushed)
275
+ return { kind: "stopped", reason: "head_changed" };
276
+ if (pr.headRefName !== ctx.workBranch)
277
+ return { kind: "stopped", reason: "head_branch_changed" };
278
+ if (pr.baseRefName !== input.expectedBaseBranch)
279
+ return { kind: "stopped", reason: "base_changed" };
280
+ if (pr.mergeable === "conflicting")
281
+ return { kind: "stopped", reason: "pr_not_mergeable" };
282
+ if (pr.mergeable !== "mergeable")
283
+ return { kind: "pending", reason: "mergeability_computing" };
284
+ if (ctx.requiredChecks.length === 0)
285
+ return { kind: "ready", ciVacuous: true };
286
+ if (budget() <= 0)
287
+ return "timeout";
288
+ const poll = await boundedRace(input.pollChecks(pushed), Math.min(readMax, budget()), input.signal);
289
+ if (poll === "aborted")
290
+ return "aborted";
291
+ if (poll instanceof ProviderChecksUnobservable)
292
+ return { kind: "unobservable" };
293
+ if (poll === "timeout" || poll === undefined || poll === null) {
294
+ return { kind: "pending", reason: "checks_unavailable" };
295
+ }
296
+ if (anyRequiredCheckFailed(poll, ctx.requiredChecks))
297
+ return { kind: "stopped", reason: "required_check_failed" };
298
+ if (allRequiredChecksGreen(poll, ctx.requiredChecks))
299
+ return { kind: "ready", ciVacuous: false };
300
+ return { kind: "pending", reason: "checks_pending" };
301
+ };
302
+ const unverifiedReason = () => {
303
+ if (lastPending === "checks_pending")
304
+ return "checks_still_pending";
305
+ if (lastPending === "mergeability_computing")
306
+ return "mergeability_still_computing";
307
+ return "evidence_unavailable";
308
+ };
309
+ for (;;) {
310
+ if (input.signal.aborted)
311
+ return { kind: "cancelled", elapsedMs: elapsed() };
312
+ if (budget() <= 0) {
313
+ const reason = unverifiedReason();
314
+ input.errorLog(`[executor] Stopped: ${reason === "evidence_unavailable" ? "evidence unavailable" : reason} at the job ` +
315
+ `deadline — pushed work preserved; the repair is UNVERIFIED, not failed CI; ${ids}; elapsed=${secs()}`);
316
+ return { kind: "stopped", proven: false, reason, elapsedMs: elapsed() };
317
+ }
318
+ const reading = await observe();
319
+ if (reading === "aborted")
320
+ return { kind: "cancelled", elapsedMs: elapsed() };
321
+ if (reading === "timeout")
322
+ continue;
323
+ if (reading.kind === "ready") {
324
+ input.errorLog(`[executor] Ready: focused rebase evidence current — pushed work at the pull request head is mergeable ` +
325
+ `and ${reading.ciVacuous ? "no required checks are configured (vacuous CI)" : "every required check is green"}; ` +
326
+ `submitting completion once; ${ids}; elapsed=${secs()}`);
327
+ return { kind: "ready", ciVacuous: reading.ciVacuous, elapsedMs: elapsed() };
328
+ }
329
+ if (reading.kind === "stopped") {
330
+ input.errorLog(`[executor] Stopped: ${reading.reason} — pushed work preserved, no further repair launched; ${ids}; elapsed=${secs()}`);
331
+ return { kind: "stopped", proven: true, reason: reading.reason, elapsedMs: elapsed() };
332
+ }
333
+ if (reading.kind === "unobservable") {
334
+ input.errorLog(`[executor] Stopped: checks_unobservable — this executor has no Bridge API access to read required ` +
335
+ `checks; pushed work preserved; the repair is UNVERIFIED; ${ids}; elapsed=${secs()}`);
336
+ return { kind: "stopped", proven: false, reason: "checks_unobservable", elapsedMs: elapsed() };
337
+ }
338
+ // Pending: log once per distinct reason so a long wait stays bounded in stderr.
339
+ if (reading.reason !== lastPending) {
340
+ input.errorLog(`[executor] Waiting for mergeability/required checks (${reading.reason}) — pushed work preserved; the ` +
341
+ `executor supervises this wait and will not launch another worker; ${ids}; elapsed=${secs()}`);
342
+ }
343
+ lastPending = reading.reason;
344
+ const pause = Math.min(interval, budget());
345
+ if (pause > 0) {
346
+ const slept = await boundedRace(input.sleep(pause), pause + 1_000, input.signal);
347
+ if (slept === "aborted")
348
+ return { kind: "cancelled", elapsedMs: elapsed() };
349
+ }
350
+ }
351
+ }
@@ -39,13 +39,16 @@
39
39
  * refuses the push is reported as `ReconcileOverlapPushUnauthorized`.
40
40
  */
41
41
  import path from "node:path";
42
- import { pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
43
42
  import { normalizeRepoRelativePath } from "../conductor/file-scope-guard.js";
44
- import { allRequiredChecksGreen } from "../conductor/local-merge.js";
43
+ // BAPI-1311: the read-only poller and predicates moved to `provider-checks.ts` so
44
+ // the focused rebase wait shares them; re-exported here for existing importers.
45
+ import { allRequiredChecksGreen, anyRequiredCheckFailed, lazyProviderCheckPoller, ProviderChecksUnobservable, } from "./provider-checks.js";
46
+ export { lazyProviderCheckPoller, ProviderChecksUnobservable };
45
47
  import { buildExecutorBaseWorkerEnv } from "./env.js";
46
48
  import { ReconcileOverlapBranchMismatch, ReconcileOverlapConflictsUnresolved, ReconcileOverlapMergeFailed, ReconcileOverlapNothingToMerge, ReconcileOverlapPayload as ReconcileOverlapPayloadInvalid, ReconcileOverlapPushRejected, ReconcileOverlapPushUnauthorized, ReconcileOverlapTopologyRefused, ReconcileOverlapVerificationRed, ReconcileOverlapWorkerRewroteHistory, } from "./job-errors.js";
47
49
  import { classifyMergeTree } from "./merge-tree-classifier.js";
48
50
  import { RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION, } from "./reconcile-overlap-types.js";
51
+ import { runRequiredVerificationCommands } from "./required-verification.js";
49
52
  import { validateWorkerFinalization } from "./worker-finalization.js";
50
53
  export const RECONCILE_OVERLAP_JOB_TYPE = "reconcile_overlap";
51
54
  /** Result-envelope bounds, mirrored from the server's result model. */
@@ -88,14 +91,6 @@ const WRAPPER_STAGE_EXCLUDES = [
88
91
  ":(exclude).claude/settings.local.json",
89
92
  ];
90
93
  const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
91
- const FAILED_CHECK_CONCLUSIONS = new Set([
92
- "failure",
93
- "cancelled",
94
- "timed_out",
95
- "action_required",
96
- "startup_failure",
97
- "stale",
98
- ]);
99
94
  const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
100
95
  const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
101
96
  class PayloadError extends Error {
@@ -259,28 +254,6 @@ export function validateReconcileOverlapPayload(job) {
259
254
  /** Thrown between steps when the supervising phase has been aborted. */
260
255
  export class ReconcileOverlapAborted extends Error {
261
256
  }
262
- /** Thrown by a provider-check poller that has no Bridge API access at all. */
263
- export class ProviderChecksUnobservable extends Error {
264
- }
265
- /**
266
- * A provider-check poller that resolves Bridge API access once, on first use —
267
- * the same read-only CI poll the `merge` job waits on. Without access it throws
268
- * {@link ProviderChecksUnobservable}, and the wait ends immediately rather than
269
- * sleeping out the job budget; the server's completion contract then reads the
270
- * checks itself.
271
- */
272
- export function lazyProviderCheckPoller(accessDeps) {
273
- let access;
274
- return async (sha) => {
275
- if (access === undefined) {
276
- const resolved = await resolveConductorBridgeApiAccess(accessDeps);
277
- access = resolved.ok ? resolved.access : null;
278
- }
279
- if (access === null)
280
- throw new ProviderChecksUnobservable("bridge api access unavailable");
281
- return pollCiChecksForCommit(access, sha);
282
- };
283
- }
284
257
  function bounded(text, max) {
285
258
  if (text.length <= max)
286
259
  return text;
@@ -290,6 +263,18 @@ function bounded(text, max) {
290
263
  function tail(text) {
291
264
  return text.length <= COMMAND_OUTPUT_TAIL_CHARS ? text : "…" + text.slice(-COMMAND_OUTPUT_TAIL_CHARS);
292
265
  }
266
+ /**
267
+ * The overlap result contract's conclusion for each shared-core outcome. The
268
+ * vocabulary is this lane's own and predates BAPI-1302; it does not change.
269
+ */
270
+ const OVERLAP_CONCLUSIONS = {
271
+ exited_zero: "success",
272
+ exited_nonzero: "failure",
273
+ timed_out: "timed_out",
274
+ launch_error: "error",
275
+ cancelled: "error",
276
+ not_run: "not_run",
277
+ };
293
278
  /** The deduplicated union of both tickets' commands, child first. */
294
279
  export function verificationCommandUnion(child, peer) {
295
280
  const seen = new Set();
@@ -329,24 +314,6 @@ export function buildReconcileEnvelope(payload, facts, extra = {}) {
329
314
  }
330
315
  return envelope;
331
316
  }
332
- function anyRequiredCheckFailed(pollResponse, required) {
333
- if (pollResponse === null || typeof pollResponse !== "object")
334
- return false;
335
- let obj = pollResponse;
336
- const detail = obj.detail;
337
- if (detail && typeof detail === "object" && Array.isArray(detail.checks)) {
338
- obj = detail;
339
- }
340
- const checks = Array.isArray(obj.checks) ? obj.checks : [];
341
- const wanted = new Set(required);
342
- return checks.some((c) => {
343
- if (!c || typeof c !== "object")
344
- return false;
345
- const name = typeof c.name === "string" ? c.name : "";
346
- const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
347
- return wanted.has(name) && FAILED_CHECK_CONCLUSIONS.has(conclusion);
348
- });
349
- }
350
317
  // ---------------------------------------------------------------------------
351
318
  // The driver
352
319
  // ---------------------------------------------------------------------------
@@ -469,43 +436,69 @@ export function createReconcileOverlapDriver(job, payload, seams) {
469
436
  }
470
437
  return lines.join("\n");
471
438
  };
472
- /** Run the union of local verification commands. Never a provider check. */
439
+ /**
440
+ * Run the union of local verification commands. Never a provider check.
441
+ *
442
+ * BAPI-1302: the subprocess work goes through the shared list-based core in
443
+ * `required-verification.ts`. Everything that is overlap POLICY stays here:
444
+ * the child/peer union and its order, the `CI_WAIT_MARGIN_MS` reservation,
445
+ * running every command even after one fails or times out, the conclusion
446
+ * vocabulary its result contract reports, and the worker environment passed
447
+ * explicitly on every check. The launch seam keeps this lane's historical
448
+ * `runCommand` behavior, including its elapsed-time timeout reading.
449
+ */
473
450
  const runVerification = async (ctx) => {
474
- const runs = [];
475
- for (const command of commands) {
476
- if (ctx.signal.aborted)
477
- throw new ReconcileOverlapAborted("reconciliation phase aborted");
478
- const budget = Math.min(command.timeoutSeconds * 1000, ctx.remainingMs() - CI_WAIT_MARGIN_MS);
479
- let conclusion;
480
- let output = "";
481
- if (budget <= 0) {
482
- conclusion = "not_run";
451
+ if (ctx.signal.aborted)
452
+ throw new ReconcileOverlapAborted("reconciliation phase aborted");
453
+ const launch = async (request) => {
454
+ const [file, ...args] = request.argv;
455
+ const started = seams.now();
456
+ try {
457
+ const result = await seams.runCommand(file, args, {
458
+ cwd: request.cwd,
459
+ timeoutMs: request.timeoutMs,
460
+ // The SAME secret-free environment a worker gets, including the
461
+ // sentinel database target: a verification command can no more reach
462
+ // the operator's real database than a worker can.
463
+ env: request.env,
464
+ });
465
+ const output = `${result.stdout}\n${result.stderr}`;
466
+ if (result.exitCode === 0)
467
+ return { outcome: { kind: "exited", exitCode: 0 }, output };
468
+ return seams.now() - started >= request.timeoutMs - 50
469
+ ? { outcome: { kind: "timed_out" }, output }
470
+ : { outcome: { kind: "exited", exitCode: result.exitCode }, output };
483
471
  }
484
- else {
485
- const started = seams.now();
486
- try {
487
- const [file, ...args] = command.argv;
488
- const result = await seams.runCommand(file, args, {
489
- cwd: ctx.worktreePath,
490
- timeoutMs: budget,
491
- // The SAME secret-free environment a worker gets, including the
492
- // sentinel database target: a verification command can no more reach
493
- // the operator's real database than a worker can.
494
- env: workerEnv,
495
- });
496
- output = `${result.stdout}\n${result.stderr}`;
497
- if (result.exitCode === 0)
498
- conclusion = "success";
499
- else
500
- conclusion = seams.now() - started >= budget - 50 ? "timed_out" : "failure";
501
- }
502
- catch {
503
- conclusion = "error";
504
- }
472
+ catch {
473
+ return { outcome: { kind: "launch_error" } };
505
474
  }
506
- const observation = { name: command.name, conclusion };
507
- facts.checks.set(command.name, conclusion);
508
- runs.push({ observation, green: conclusion === "success", output });
475
+ };
476
+ const { facts: runFacts } = await runRequiredVerificationCommands({
477
+ commands: commands.map((command, position) => ({
478
+ identity: `overlap-${position}`,
479
+ position,
480
+ name: command.name,
481
+ argv: command.argv,
482
+ timeoutSeconds: command.timeoutSeconds,
483
+ })),
484
+ worktreePath: ctx.worktreePath,
485
+ env: workerEnv,
486
+ launch,
487
+ now: seams.now,
488
+ // The overlap lane keeps its CI margin: checks may use only what is left
489
+ // after reserving the post-push CI wait.
490
+ deadlineAtMs: seams.now() + ctx.remainingMs() - CI_WAIT_MARGIN_MS,
491
+ signal: ctx.signal,
492
+ onInterruption: "continue",
493
+ });
494
+ if (ctx.signal.aborted)
495
+ throw new ReconcileOverlapAborted("reconciliation phase aborted");
496
+ const runs = [];
497
+ for (const fact of runFacts) {
498
+ const conclusion = OVERLAP_CONCLUSIONS[fact.outcome];
499
+ const observation = { name: fact.name, conclusion };
500
+ facts.checks.set(fact.name, conclusion);
501
+ runs.push({ observation, green: conclusion === "success", output: fact.output });
509
502
  }
510
503
  return runs;
511
504
  };