@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
@@ -14,6 +14,7 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
14
14
  let escalated = false;
15
15
  let disposed = false;
16
16
  let graceTimer;
17
+ const killListeners = [];
17
18
  return {
18
19
  requestTermination() {
19
20
  // A disposed controller belongs to a settled child: signalling it would
@@ -28,6 +29,14 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
28
29
  return;
29
30
  escalated = true;
30
31
  proc.kill("SIGKILL");
32
+ for (const listener of killListeners.splice(0)) {
33
+ try {
34
+ listener();
35
+ }
36
+ catch {
37
+ /* a listener must never break the escalation path */
38
+ }
39
+ }
31
40
  }, termGraceMs);
32
41
  },
33
42
  isTerminationRequested() {
@@ -41,6 +50,16 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
41
50
  deps.clearTimer(graceTimer);
42
51
  graceTimer = undefined;
43
52
  }
53
+ killListeners.length = 0;
54
+ },
55
+ onKillSent(listener) {
56
+ if (escalated) {
57
+ listener();
58
+ return;
59
+ }
60
+ if (disposed)
61
+ return;
62
+ killListeners.push(listener);
44
63
  },
45
64
  };
46
65
  }
@@ -55,16 +74,23 @@ function appendBounded(existing, chunk, limitBytes) {
55
74
  return combined;
56
75
  return Buffer.from(combined, "utf8").subarray(0, limitBytes).toString("utf8");
57
76
  }
77
+ /**
78
+ * Drain one output stream into `onChunk`. Resolves `"eof"` at end of stream and
79
+ * `"stream_error"` when iteration threw (BAPI-1301 reports which, for the
80
+ * authoritative stdout path; legacy callers ignore it). A null stream is `"eof"`.
81
+ */
58
82
  async function pump(iterable, onChunk) {
59
83
  if (!iterable)
60
- return;
84
+ return "eof";
61
85
  try {
62
86
  for await (const chunk of iterable) {
63
87
  onChunk(typeof chunk === "string" ? chunk : String(chunk));
64
88
  }
89
+ return "eof";
65
90
  }
66
91
  catch {
67
92
  /* stream errors degrade observability, never correctness */
93
+ return "stream_error";
68
94
  }
69
95
  }
70
96
  /**
@@ -100,10 +126,37 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
100
126
  // single grace timer rather than racing three of each.
101
127
  const controller = options.terminationController ?? createProcessTerminationController(proc, deps, termGraceMs);
102
128
  const terminate = () => controller.requestTermination();
129
+ // BAPI-1301 (D4a): the authoritative consumer, when supplied. `drainClosed`
130
+ // flips once the bounded post-exit drain gives up, so a chunk arriving after
131
+ // the verdict was taken can never mutate the ledger behind the caller's back.
132
+ const authoritative = options.authoritativeStdout;
133
+ const supervised = authoritative !== undefined || options.drainDeadlineMs !== undefined;
134
+ let authoritativeStreamError = false;
135
+ let drainClosed = false;
103
136
  const pumpStdout = pump(proc.stdout, (chunk) => {
137
+ if (drainClosed)
138
+ return;
104
139
  // Advisory observation ALWAYS runs first and always runs: the tee and the
105
140
  // telemetry must see every chunk regardless of what the assertion decides.
106
- options.onStdout?.(chunk);
141
+ if (authoritative !== undefined) {
142
+ // Guarded only on the authoritative path: an advisory throw must not end
143
+ // the pump and truncate the stream the ledger depends on.
144
+ try {
145
+ options.onStdout?.(chunk);
146
+ }
147
+ catch {
148
+ /* advisory — never affects the run */
149
+ }
150
+ try {
151
+ authoritative(chunk);
152
+ }
153
+ catch {
154
+ authoritativeStreamError = true;
155
+ }
156
+ }
157
+ else {
158
+ options.onStdout?.(chunk);
159
+ }
107
160
  stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
108
161
  if (options.onStdoutTerminationCheck?.(chunk) === true && !terminationRequested) {
109
162
  terminationRequested = true;
@@ -120,14 +173,58 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
120
173
  // races an already-requested termination a no-op rather than a double signal.
121
174
  terminate();
122
175
  }, timeoutSeconds * 1000);
123
- const { exitCode, signal } = await proc.wait();
176
+ // BAPI-1301 (D4a): bounded waits, only when a drain deadline was supplied.
177
+ // Every timer created here is cleared before returning.
178
+ const drainMs = options.drainDeadlineMs === undefined ? undefined : Math.max(0, options.drainDeadlineMs);
179
+ const drainTimers = [];
180
+ const after = (ms) => new Promise((resolve) => {
181
+ drainTimers.push(deps.setTimer(resolve, ms));
182
+ });
183
+ let exitCode;
184
+ let signal;
185
+ let exitUnconfirmed = false;
186
+ if (drainMs === undefined) {
187
+ ({ exitCode, signal } = await proc.wait());
188
+ }
189
+ else {
190
+ const racers = [
191
+ proc.wait().then((r) => ({ kind: "closed", ...r })),
192
+ ];
193
+ // The process `exit`, independent of its pipes: a descendant holding stdout
194
+ // open must not stop supervision from noticing the child itself is gone.
195
+ if (proc.exited)
196
+ racers.push(proc.exited().then((r) => ({ kind: "exited", ...r })));
197
+ // After SIGKILL, `wait()` gets at most `drainMs` more before the exit is
198
+ // reported unconfirmed rather than awaited forever.
199
+ racers.push(new Promise((resolve) => controller.onKillSent?.(resolve))
200
+ .then(() => after(drainMs))
201
+ .then(() => ({ kind: "kill_bound", exitCode: null, signal: null })));
202
+ const settled = await Promise.race(racers);
203
+ exitCode = settled.exitCode;
204
+ signal = settled.signal;
205
+ exitUnconfirmed = settled.kind === "kill_bound";
206
+ }
124
207
  deps.clearTimer(timeoutTimer);
125
208
  // The child has settled, so any outstanding escalation is now aimed at a
126
209
  // finished process. Disposal is idempotent and safe for a controller this call
127
210
  // did not create — the job runner's `finally` may dispose it again.
128
211
  controller.dispose();
129
- await pumpStdout;
130
- await pumpStderr;
212
+ let stdoutEnd;
213
+ if (drainMs === undefined) {
214
+ stdoutEnd = await pumpStdout;
215
+ await pumpStderr;
216
+ }
217
+ else {
218
+ const drained = await Promise.race([
219
+ Promise.all([pumpStdout, pumpStderr]).then(([out]) => out),
220
+ after(drainMs).then(() => "drain_deadline"),
221
+ ]);
222
+ if (drained === "drain_deadline")
223
+ drainClosed = true;
224
+ stdoutEnd = drained;
225
+ for (const handle of drainTimers)
226
+ deps.clearTimer(handle);
227
+ }
131
228
  let classification;
132
229
  if (timedOut) {
133
230
  classification = "timeout";
@@ -145,6 +242,11 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
145
242
  stdoutExcerpt,
146
243
  stderrExcerpt,
147
244
  ...(terminationRequested ? { terminationRequested: true } : {}),
245
+ // BAPI-1301 fields appear only for callers that opted in, so every legacy
246
+ // result keeps its exact shape.
247
+ ...(supervised ? { stdoutEnd } : {}),
248
+ ...(authoritativeStreamError ? { authoritativeStreamError: true } : {}),
249
+ ...(exitUnconfirmed ? { exitUnconfirmed: true } : {}),
148
250
  };
149
251
  }
150
252
  /**
@@ -155,3 +257,131 @@ export function killOwnedProcess(proc, reason) {
155
257
  proc.kill("SIGKILL");
156
258
  return { classification: "killed", reason };
157
259
  }
260
+ /**
261
+ * Wait for `success` on `stdin`, bounded by abort, deadline, `close`, and `error`.
262
+ * `action` runs AFTER every listener is registered, so an implementation (or a
263
+ * fake) that emits synchronously inside `end()` is still observed. Every
264
+ * listener and timer is removed on settle. Never rejects.
265
+ */
266
+ function awaitStdinOutcome(stdin, success, options, action) {
267
+ return new Promise((resolve) => {
268
+ let settled = false;
269
+ let timer;
270
+ const registered = [];
271
+ const onAbort = () => finish({ ok: false, reason: "aborted" });
272
+ function finish(result) {
273
+ if (settled)
274
+ return;
275
+ settled = true;
276
+ if (timer !== undefined)
277
+ options.deps.clearTimer(timer);
278
+ for (const [event, listener] of registered) {
279
+ try {
280
+ stdin.off?.(event, listener);
281
+ }
282
+ catch {
283
+ /* best-effort listener cleanup */
284
+ }
285
+ }
286
+ options.signal?.removeEventListener("abort", onAbort);
287
+ resolve(result);
288
+ }
289
+ const listen = (event, result) => {
290
+ const listener = () => finish(result);
291
+ registered.push([event, listener]);
292
+ try {
293
+ stdin.once(event, listener);
294
+ }
295
+ catch {
296
+ finish({ ok: false, reason: "write_error" });
297
+ }
298
+ };
299
+ listen(success, { ok: true });
300
+ listen("error", { ok: false, reason: "write_error" });
301
+ listen("close", { ok: false, reason: "closed" });
302
+ if (settled)
303
+ return;
304
+ if (options.signal) {
305
+ if (options.signal.aborted) {
306
+ finish({ ok: false, reason: "aborted" });
307
+ return;
308
+ }
309
+ options.signal.addEventListener("abort", onAbort, { once: true });
310
+ }
311
+ const remaining = options.deadlineAtMs - options.deps.now();
312
+ if (remaining <= 0) {
313
+ finish({ ok: false, reason: "deadline" });
314
+ return;
315
+ }
316
+ timer = options.deps.setTimer(() => {
317
+ timer = undefined;
318
+ finish({ ok: false, reason: "deadline" });
319
+ }, remaining);
320
+ if (action) {
321
+ try {
322
+ action();
323
+ }
324
+ catch {
325
+ finish({ ok: false, reason: "write_error" });
326
+ }
327
+ }
328
+ });
329
+ }
330
+ /** Pre-checks shared by write and close: stdin present, not aborted, deadline not passed. */
331
+ function stdinPrecheck(proc, options) {
332
+ const stdin = proc.stdin;
333
+ if (!stdin)
334
+ return { failure: { ok: false, reason: "no_stdin" } };
335
+ if (options.signal?.aborted)
336
+ return { failure: { ok: false, reason: "aborted" } };
337
+ if (options.deps.now() >= options.deadlineAtMs)
338
+ return { failure: { ok: false, reason: "deadline" } };
339
+ return { stdin };
340
+ }
341
+ /**
342
+ * Write `data` to a held child stdin, observing Node backpressure (BAPI-1301).
343
+ *
344
+ * When `write()` accepts the data below the high-water mark this resolves at
345
+ * once. When it returns `false`, it awaits `drain` — but only until the attempt
346
+ * deadline, the abort signal, the pipe closing, or a pipe error, whichever comes
347
+ * first. It NEVER throws: every failure is a normalized
348
+ * {@link StdinTransportResult} the caller turns into an evidence fact
349
+ * (`transport_failed_before_work` / `transport_failed_after_progress`).
350
+ */
351
+ export async function writeStdinBounded(proc, data, options) {
352
+ const checked = stdinPrecheck(proc, options);
353
+ if ("failure" in checked)
354
+ return checked.failure;
355
+ const { stdin } = checked;
356
+ if (stdin.destroyed === true || stdin.writableEnded === true) {
357
+ return { ok: false, reason: "closed" };
358
+ }
359
+ let accepted;
360
+ try {
361
+ accepted = stdin.write(data);
362
+ }
363
+ catch {
364
+ return { ok: false, reason: "write_error" };
365
+ }
366
+ if (accepted)
367
+ return { ok: true };
368
+ return awaitStdinOutcome(stdin, "drain", options);
369
+ }
370
+ /**
371
+ * Half-close a held child stdin — EOF for the child — bounded the same way
372
+ * (BAPI-1301). Tolerates an already-ended pipe (`{ ok: true }`: EOF was already
373
+ * delivered); a destroyed pipe is `closed`. Waits for `finish` (the flush) only
374
+ * until deadline/abort/close/error. Never throws.
375
+ */
376
+ export async function closeStdinBounded(proc, options) {
377
+ // An already-ended pipe is success even past the deadline: EOF was delivered.
378
+ if (proc.stdin?.writableEnded === true)
379
+ return { ok: true };
380
+ const checked = stdinPrecheck(proc, options);
381
+ if ("failure" in checked)
382
+ return checked.failure;
383
+ const { stdin } = checked;
384
+ if (stdin.destroyed === true)
385
+ return { ok: false, reason: "closed" };
386
+ return awaitStdinOutcome(stdin, "finish", options, () => stdin.end());
387
+ }
@@ -17,7 +17,7 @@
17
17
  * placeholder name). This module NEVER logs prompt text, ticket specs, git logs,
18
18
  * or reasons.
19
19
  */
20
- import { ExecutorNamedError } from "./job-errors.js";
20
+ import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
21
21
  /** Stable named-error kind used for every prompt-spec contract failure. */
22
22
  const PROMPT_CONTRACT_ERROR = "ContractError.Prompt";
23
23
  /** The prompt-spec version this renderer understands. */
@@ -55,7 +55,253 @@ const SUPPORTED_PLACEHOLDERS = new Set([
55
55
  "REVISE_REASONS",
56
56
  "RELATED_CONTEXT",
57
57
  "SPEC_CITATION_FRESHNESS_WARNING",
58
+ // BAPI-1296 — resolved ONLY from a validated `ci_fix` dispatch context
59
+ // (`readCiFixDispatchContext`), never from configured required checks.
60
+ "FAILING_CHECKS",
61
+ "MISSING_CHECKS",
62
+ "LOG_EXCERPTS",
58
63
  ]);
64
+ // ---------------------------------------------------------------------------
65
+ // BAPI-1296 — the focused `ci_fix` dispatch contract (executor half).
66
+ //
67
+ // The reconciler writes `payload.ci_resolved_inputs` from the per-head CI
68
+ // observation (`_ci_fix_resolved_inputs_from_observation` in
69
+ // `api/library/epic_implementer/reconciler.py`). These bounds MIRROR the
70
+ // server's, and an over-bound essential value is REFUSED, never truncated: a
71
+ // check silently dropped here would be a check the worker is never told about.
72
+ // Only excerpt diagnostic TEXT may be cut, and visibly.
73
+ // ---------------------------------------------------------------------------
74
+ /** Most distinct actionable (failing ∪ missing) checks. */
75
+ export const CI_FIX_MAX_ACTIONABLE_CHECKS = 32;
76
+ /** Longest check name, in UTF-8 bytes. */
77
+ export const CI_FIX_MAX_CHECK_NAME_BYTES = 200;
78
+ /** Most log excerpts carried into the prompt. */
79
+ export const CI_FIX_MAX_LOG_EXCERPTS = 8;
80
+ /** Longest single excerpt text, in UTF-8 bytes (cut visibly beyond it). */
81
+ export const CI_FIX_MAX_LOG_EXCERPT_BYTES = 512;
82
+ /** Total excerpt text across all excerpts, in UTF-8 bytes. */
83
+ export const CI_FIX_MAX_LOG_EXCERPTS_TOTAL_BYTES = 4_096;
84
+ /** Longest excerpt `source` label, in UTF-8 bytes. */
85
+ const CI_FIX_MAX_EXCERPT_SOURCE_BYTES = 200;
86
+ /**
87
+ * Ceiling on the ticket spec a ci_fix prompt carries. The server bounds the spec
88
+ * to 64,000 characters plus a truncation marker; anything past this is not a
89
+ * spec the server produced.
90
+ */
91
+ export const CI_FIX_TICKET_SPEC_MAX_CHARS = 70_000;
92
+ /** The exact placeholder set a focused ci_fix prompt spec must declare. */
93
+ const CI_FIX_PLACEHOLDERS = [
94
+ "TICKET_SPEC",
95
+ "WORK_BRANCH",
96
+ "FAILING_CHECKS",
97
+ "MISSING_CHECKS",
98
+ "LOG_EXCERPTS",
99
+ ];
100
+ const CI_FIX_EXCERPT_TRUNCATION_MARKER = " …[excerpt truncated]";
101
+ const CI_FIX_HEAD_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
102
+ /** Build the named, bounded refusal for a focused ci_fix job. */
103
+ export function ciFixContextError(job, reason) {
104
+ return new ExecutorNamedError(CiFixContext, `ci_fix job ${job.id} refused before worker effects (reason=${reason})`);
105
+ }
106
+ function utf8Bytes(value) {
107
+ return Buffer.byteLength(value, "utf8");
108
+ }
109
+ function hasControlCharacter(value) {
110
+ for (let i = 0; i < value.length; i++) {
111
+ const code = value.charCodeAt(i);
112
+ if (code < 0x20 || code === 0x7f)
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+ /** Validate + de-duplicate one check-name list; `null` when a name is unusable. */
118
+ function readCheckNames(raw) {
119
+ if (raw === undefined || raw === null)
120
+ return [];
121
+ if (!Array.isArray(raw))
122
+ return null;
123
+ const names = [];
124
+ const seen = new Set();
125
+ for (const item of raw) {
126
+ if (typeof item !== "string")
127
+ return null;
128
+ const name = item.trim();
129
+ if (name.length === 0 ||
130
+ utf8Bytes(name) > CI_FIX_MAX_CHECK_NAME_BYTES ||
131
+ hasControlCharacter(name)) {
132
+ return null;
133
+ }
134
+ if (!seen.has(name)) {
135
+ seen.add(name);
136
+ names.push(name);
137
+ }
138
+ }
139
+ return names;
140
+ }
141
+ function sliceUtf8(value, maxBytes) {
142
+ if (utf8Bytes(value) <= maxBytes)
143
+ return value;
144
+ // Decoding a cut multi-byte sequence yields U+FFFD; drop it so the kept
145
+ // prefix is clean text.
146
+ return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/u, "");
147
+ }
148
+ function readLogExcerpts(raw, headSha, dispatched) {
149
+ // An ABSENT record is not a reason to refuse targeted work: excerpts are
150
+ // optional evidence. It renders as plainly unavailable.
151
+ if (raw === undefined || raw === null) {
152
+ return { status: "unavailable", reason: "not_supplied" };
153
+ }
154
+ if (typeof raw !== "object" || Array.isArray(raw))
155
+ return null;
156
+ const record = raw;
157
+ if (record.status === "unavailable") {
158
+ const reason = typeof record.reason === "string" &&
159
+ /^[a-z0-9_]{1,64}$/.test(record.reason)
160
+ ? record.reason
161
+ : "unspecified";
162
+ return { status: "unavailable", reason };
163
+ }
164
+ if (record.status !== "supplied" || !Array.isArray(record.excerpts))
165
+ return null;
166
+ if (record.excerpts.length === 0 || record.excerpts.length > CI_FIX_MAX_LOG_EXCERPTS) {
167
+ return null;
168
+ }
169
+ const excerpts = [];
170
+ let totalBytes = 0;
171
+ for (const entry of record.excerpts) {
172
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
173
+ return null;
174
+ const e = entry;
175
+ // Provenance is ESSENTIAL: an excerpt that cannot name a dispatched check, a
176
+ // source, and the dispatch head is not verified evidence for this attempt.
177
+ if (typeof e.check !== "string" ||
178
+ !dispatched.has(e.check.trim()) ||
179
+ typeof e.source !== "string" ||
180
+ e.source.trim().length === 0 ||
181
+ utf8Bytes(e.source.trim()) > CI_FIX_MAX_EXCERPT_SOURCE_BYTES ||
182
+ hasControlCharacter(e.source.trim()) ||
183
+ typeof e.head_sha !== "string" ||
184
+ e.head_sha.trim().toLowerCase() !== headSha ||
185
+ typeof e.text !== "string") {
186
+ return null;
187
+ }
188
+ let text = e.text;
189
+ if (utf8Bytes(text) > CI_FIX_MAX_LOG_EXCERPT_BYTES) {
190
+ text =
191
+ sliceUtf8(text, CI_FIX_MAX_LOG_EXCERPT_BYTES - utf8Bytes(CI_FIX_EXCERPT_TRUNCATION_MARKER)) +
192
+ CI_FIX_EXCERPT_TRUNCATION_MARKER;
193
+ }
194
+ const remaining = CI_FIX_MAX_LOG_EXCERPTS_TOTAL_BYTES - totalBytes;
195
+ if (remaining <= utf8Bytes(CI_FIX_EXCERPT_TRUNCATION_MARKER))
196
+ break;
197
+ if (utf8Bytes(text) > remaining) {
198
+ text =
199
+ sliceUtf8(text, remaining - utf8Bytes(CI_FIX_EXCERPT_TRUNCATION_MARKER)) +
200
+ CI_FIX_EXCERPT_TRUNCATION_MARKER;
201
+ }
202
+ totalBytes += utf8Bytes(text);
203
+ excerpts.push({ check: e.check.trim(), source: e.source.trim(), text });
204
+ }
205
+ return { status: "supplied", excerpts };
206
+ }
207
+ /**
208
+ * Validate a claimed `ci_fix` job's focused contract WITHOUT touching a worktree.
209
+ *
210
+ * Checks, in order: no explicit `payload.prompt` (a legacy full-recipe prompt
211
+ * must not silently replace the focused route); a valid `ci_fix` prompt spec
212
+ * declaring exactly the five focused placeholders; a non-blank, bounded ticket
213
+ * spec; and a head-bound `ci_resolved_inputs` whose repository and expected
214
+ * branch agree with the claimed job. Throws `ContractError.CiFixContext` with a
215
+ * fixed reason token on the first failure — never a fallback prompt.
216
+ */
217
+ export function readCiFixDispatchContext(job) {
218
+ const payload = job.payload && typeof job.payload === "object"
219
+ ? job.payload
220
+ : {};
221
+ if (typeof payload.prompt === "string" && payload.prompt.trim().length > 0) {
222
+ throw ciFixContextError(job, "explicit_prompt_unsupported");
223
+ }
224
+ let spec;
225
+ try {
226
+ spec = readPromptSpecPayload(payload.prompt_spec, "ci_fix");
227
+ }
228
+ catch {
229
+ throw ciFixContextError(job, "prompt_spec_invalid");
230
+ }
231
+ if (spec.placeholders.length !== CI_FIX_PLACEHOLDERS.length ||
232
+ !CI_FIX_PLACEHOLDERS.every((name) => spec.placeholders.includes(name))) {
233
+ throw ciFixContextError(job, "prompt_spec_invalid");
234
+ }
235
+ if (!isNonBlankString(payload.ticket_spec)) {
236
+ throw ciFixContextError(job, "ticket_spec_missing");
237
+ }
238
+ if (payload.ticket_spec.length > CI_FIX_TICKET_SPEC_MAX_CHARS) {
239
+ throw ciFixContextError(job, "ticket_spec_oversized");
240
+ }
241
+ const inputs = payload.ci_resolved_inputs;
242
+ if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
243
+ throw ciFixContextError(job, "dispatched_checks_missing");
244
+ }
245
+ const raw = inputs;
246
+ const failing = readCheckNames(raw.failing_checks);
247
+ const missing = readCheckNames(raw.missing_checks);
248
+ if (failing === null || missing === null) {
249
+ throw ciFixContextError(job, "check_name_invalid");
250
+ }
251
+ const required = [];
252
+ const seen = new Set();
253
+ for (const name of [...failing, ...missing]) {
254
+ if (!seen.has(name)) {
255
+ seen.add(name);
256
+ required.push(name);
257
+ }
258
+ }
259
+ if (required.length === 0) {
260
+ throw ciFixContextError(job, "dispatched_checks_missing");
261
+ }
262
+ if (required.length > CI_FIX_MAX_ACTIONABLE_CHECKS) {
263
+ throw ciFixContextError(job, "too_many_checks");
264
+ }
265
+ const headSha = typeof raw.head_sha === "string" ? raw.head_sha.trim().toLowerCase() : "";
266
+ const repoName = typeof raw.repo_name === "string" ? raw.repo_name.trim() : "";
267
+ const expectedBranch = typeof raw.expected_branch === "string" ? raw.expected_branch.trim() : "";
268
+ if (!CI_FIX_HEAD_SHA_RE.test(headSha) || !repoName || !expectedBranch) {
269
+ // A pre-BAPI-1296 payload carries the check lists without a head binding:
270
+ // it cannot be proven to describe the checkout this executor will prepare.
271
+ throw ciFixContextError(job, "identity_missing");
272
+ }
273
+ const jobExpectedBranch = typeof job.expected_branch === "string" ? job.expected_branch.trim() : "";
274
+ if (repoName !== job.repo_name || (jobExpectedBranch && jobExpectedBranch !== expectedBranch)) {
275
+ throw ciFixContextError(job, "identity_mismatch");
276
+ }
277
+ const logExcerpts = readLogExcerpts(raw.log_excerpts, headSha, seen);
278
+ if (logExcerpts === null) {
279
+ throw ciFixContextError(job, "log_excerpts_invalid");
280
+ }
281
+ return {
282
+ headSha,
283
+ repoName,
284
+ expectedBranch,
285
+ failingChecks: failing,
286
+ missingChecks: missing,
287
+ requiredClaimChecks: required,
288
+ logExcerpts,
289
+ };
290
+ }
291
+ function formatCheckList(names, none) {
292
+ return names.length > 0 ? names.map((name) => `- ${name}`).join("\n") : none;
293
+ }
294
+ function formatLogExcerpts(excerpts) {
295
+ if (excerpts.status === "unavailable") {
296
+ return ("UNAVAILABLE — no log excerpt was supplied with this job " +
297
+ `(reason: ${excerpts.reason}). No log content is provided below or ` +
298
+ "anywhere else in this prompt. Diagnose from the check names, the code, " +
299
+ "and local reproduction; do not assume what any log said.");
300
+ }
301
+ return excerpts.excerpts
302
+ .map((e) => `--- check: ${e.check} (source: ${e.source}) ---\n${e.text}`)
303
+ .join("\n\n");
304
+ }
59
305
  /**
60
306
  * Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
61
307
  *
@@ -229,6 +475,21 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
229
475
  : RELATED_CONTEXT_NONE;
230
476
  break;
231
477
  }
478
+ case "FAILING_CHECKS":
479
+ case "MISSING_CHECKS":
480
+ case "LOG_EXCERPTS": {
481
+ // BAPI-1296: only a validated ci_fix dispatch context resolves these.
482
+ // Any other job type declaring them is a contract failure, and the
483
+ // repository's required-check configuration is never a source.
484
+ if (job.job_type !== "ci_fix") {
485
+ throw promptContractError(`prompt_spec for job ${job.id} declares ci_fix-only placeholder '${name}'`);
486
+ }
487
+ const ci = readCiFixDispatchContext(job);
488
+ values.FAILING_CHECKS = formatCheckList(ci.failingChecks, "(none — no check was observed failing on the dispatched head)");
489
+ values.MISSING_CHECKS = formatCheckList(ci.missingChecks, "(none — every required check reported on the dispatched head)");
490
+ values.LOG_EXCERPTS = formatLogExcerpts(ci.logExcerpts);
491
+ break;
492
+ }
232
493
  case "SPEC_CITATION_FRESHNESS_WARNING": {
233
494
  // OPTIONAL by contract (BAPI-722): an absent field is the normal case and
234
495
  // resolves to the explicit "none" text, NEVER a thrown contract error and
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Read-only provider-check helpers shared by the executor's post-push waits
3
+ * (BAPI-1152, extracted in BAPI-1311).
4
+ *
5
+ * `reconcile_overlap` waited on provider CI after its guarded push; BAPI-1311's
6
+ * focused `rebase` waits on the same evidence after its worker's push. Both need
7
+ * one poller and one pair of predicates, so they live here rather than being
8
+ * copied. Everything in this module is READ-ONLY: it polls the Bridge API's
9
+ * existing `/poll-ci-checks` read and classifies the response. It resets no
10
+ * worktree, pushes nothing, and decides no gate — the server's completion
11
+ * verifier stays the authority for both lanes (R14 rule 3).
12
+ */
13
+ import { pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
14
+ import { allRequiredChecksGreen } from "../conductor/local-merge.js";
15
+ export { allRequiredChecksGreen };
16
+ /** Thrown by a provider-check poller that has no Bridge API access at all. */
17
+ export class ProviderChecksUnobservable extends Error {
18
+ }
19
+ /**
20
+ * A provider-check poller that resolves Bridge API access once, on first use —
21
+ * the same read-only CI poll the `merge` job waits on. Without access it throws
22
+ * {@link ProviderChecksUnobservable}, so a caller can end its wait immediately
23
+ * rather than sleeping out the job budget on evidence it can never read.
24
+ */
25
+ export function lazyProviderCheckPoller(accessDeps) {
26
+ let access;
27
+ return async (sha) => {
28
+ if (access === undefined) {
29
+ const resolved = await resolveConductorBridgeApiAccess(accessDeps);
30
+ access = resolved.ok ? resolved.access : null;
31
+ }
32
+ if (access === null)
33
+ throw new ProviderChecksUnobservable("bridge api access unavailable");
34
+ return pollCiChecksForCommit(access, sha);
35
+ };
36
+ }
37
+ /** Conclusions that PROVE a check failed, as opposed to not having finished. */
38
+ export const FAILED_CHECK_CONCLUSIONS = new Set([
39
+ "failure",
40
+ "cancelled",
41
+ "timed_out",
42
+ "action_required",
43
+ "startup_failure",
44
+ "stale",
45
+ ]);
46
+ /**
47
+ * True when any REQUIRED check reports a conclusion in
48
+ * {@link FAILED_CHECK_CONCLUSIONS}. A running, queued, or missing check is not a
49
+ * failure. Envelope-tolerant: `pollCiChecksForCommit` returns
50
+ * `{available, reason, action, detail}` with the checks under `detail`.
51
+ */
52
+ export function anyRequiredCheckFailed(pollResponse, required) {
53
+ if (pollResponse === null || typeof pollResponse !== "object")
54
+ return false;
55
+ let obj = pollResponse;
56
+ const detail = obj.detail;
57
+ if (detail && typeof detail === "object" && Array.isArray(detail.checks)) {
58
+ obj = detail;
59
+ }
60
+ const checks = Array.isArray(obj.checks) ? obj.checks : [];
61
+ const wanted = new Set(required);
62
+ return checks.some((c) => {
63
+ if (!c || typeof c !== "object")
64
+ return false;
65
+ const name = typeof c.name === "string" ? c.name : "";
66
+ const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
67
+ return wanted.has(name) && FAILED_CHECK_CONCLUSIONS.has(conclusion);
68
+ });
69
+ }