@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,286 @@
1
+ import { sha256Hex } from "./completion-contract.js";
2
+ import { canonicalJson } from "./required-verification.js";
3
+ /** Reserved for the owned-process sweep after the worker exits. */
4
+ export const CLEANUP_RESERVE_MS = 20_000;
5
+ /** Reserved for `validateWorkerFinalization` (git + gh reads). */
6
+ export const FINALIZATION_RESERVE_MS = 60_000;
7
+ /** Reserved for preparing and delivering the terminal report, with retries. */
8
+ export const REPORT_RESERVE_MS = 30_000;
9
+ /** Reserved for cleanup after declared checks, when any are declared. */
10
+ export const CHECK_CLEANUP_RESERVE_MS = 10_000;
11
+ /**
12
+ * Between closing stdin at the hold deadline and the process timer's SIGTERM.
13
+ * The measured CLI stops its own tasks within ~0.4 s of stdin EOF; the rest is
14
+ * margin for EOF draining.
15
+ */
16
+ export const STDIN_CLOSE_GRACE_MS = 10_000;
17
+ /** A worker window shorter than this cannot do useful work: refuse instead. */
18
+ export const MIN_WORKER_WINDOW_MS = 60_000;
19
+ /** Bound on the post-exit wait for stdout/stderr EOF. */
20
+ export const EOF_DRAIN_BOUND_MS = 5_000;
21
+ /**
22
+ * Plan the covered attempt's phases from its remaining authorized allowance.
23
+ *
24
+ * `remainingMs` is what is left of the job's own timeout at this moment (the
25
+ * caller has already spent some of it preparing the worktree), minus any
26
+ * allowance the ORIGINATING attempt already spent when this job is a
27
+ * continuation. The plan never returns a window larger than that.
28
+ */
29
+ export function planCompletionAllowance(input) {
30
+ const verificationMs = input.declaredCheckTimeoutsMs.reduce((sum, ms) => sum + Math.max(0, ms), 0);
31
+ const reservations = {
32
+ cleanupMs: CLEANUP_RESERVE_MS,
33
+ verificationMs,
34
+ checkCleanupMs: verificationMs > 0 ? CHECK_CLEANUP_RESERVE_MS : 0,
35
+ finalizationMs: FINALIZATION_RESERVE_MS,
36
+ reportMs: REPORT_RESERVE_MS,
37
+ };
38
+ const reserved = reservations.cleanupMs +
39
+ reservations.verificationMs +
40
+ reservations.checkCleanupMs +
41
+ reservations.finalizationMs +
42
+ reservations.reportMs;
43
+ const available = Math.max(0, input.remainingMs - Math.max(0, input.spentAllowanceMs ?? 0));
44
+ const workerWindowMs = available - reserved;
45
+ if (workerWindowMs < MIN_WORKER_WINDOW_MS)
46
+ return { ok: false, reason: "allowance_exhausted" };
47
+ const deadlineAtMs = input.nowMs + available;
48
+ const workerDeadlineAtMs = input.nowMs + workerWindowMs;
49
+ return {
50
+ ok: true,
51
+ deadlineAtMs,
52
+ workerDeadlineAtMs,
53
+ holdDeadlineAtMs: workerDeadlineAtMs - STDIN_CLOSE_GRACE_MS,
54
+ workerWindowMs,
55
+ reservations,
56
+ };
57
+ }
58
+ /** The payload key the server stamps the declaration under. */
59
+ export const REQUIRED_VERIFICATION_PAYLOAD_KEY = "required_verification";
60
+ export const REQUIRED_VERIFICATION_SCHEMA_VERSION = 1;
61
+ /** Bounds, identical to the server's `ReconcileVerificationCommand` / overlap contract. */
62
+ export const DECLARATION_MAX_COMMANDS = 16;
63
+ export const DECLARATION_MAX_NAME_CHARS = 120;
64
+ export const DECLARATION_MAX_ARGV = 32;
65
+ export const DECLARATION_MAX_ARG_CHARS = 1024;
66
+ export const DECLARATION_MIN_TIMEOUT_SECONDS = 1;
67
+ export const DECLARATION_MAX_TIMEOUT_SECONDS = 30 * 60;
68
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
69
+ const COMMAND_KEYS = ["argv", "identity", "name", "position", "timeout_seconds"];
70
+ const DECLARATION_KEYS = new Set([
71
+ "schema_version",
72
+ "provenance",
73
+ "plan_version",
74
+ "plan_hash",
75
+ "commands",
76
+ "digest",
77
+ "refusal_reason",
78
+ ]);
79
+ /** `cmd-<position>-<16 hex>` over the canonical typed value — the server's `command_identity`. */
80
+ export function declaredCommandIdentity(position, value) {
81
+ const typed = { name: value.name, argv: [...value.argv], timeout_seconds: value.timeout_seconds };
82
+ return `cmd-${String(position).padStart(2, "0")}-${sha256Hex(canonicalJson(typed)).slice(0, 16)}`;
83
+ }
84
+ /** SHA-256 over every declaration field except `digest` — the server's `declaration_digest`. */
85
+ export function computeDeclarationDigest(declaration) {
86
+ const body = {};
87
+ for (const [k, v] of Object.entries(declaration))
88
+ if (k !== "digest")
89
+ body[k] = v;
90
+ return sha256Hex(canonicalJson(body));
91
+ }
92
+ function isInt(value) {
93
+ return typeof value === "number" && Number.isInteger(value);
94
+ }
95
+ function validCommand(raw, index) {
96
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
97
+ return "malformed_command";
98
+ const e = raw;
99
+ const keys = Object.keys(e).sort();
100
+ if (keys.length !== COMMAND_KEYS.length || keys.some((k, i) => k !== COMMAND_KEYS[i]))
101
+ return "malformed_command";
102
+ const { identity, position, name, argv, timeout_seconds: timeout } = e;
103
+ if (typeof name !== "string" || name.trim().length === 0 || name.length > DECLARATION_MAX_NAME_CHARS) {
104
+ return "malformed_command";
105
+ }
106
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > DECLARATION_MAX_ARGV)
107
+ return "malformed_command";
108
+ for (const arg of argv) {
109
+ if (typeof arg !== "string" || arg.trim().length === 0 || arg.length > DECLARATION_MAX_ARG_CHARS || arg.includes("\0")) {
110
+ return "malformed_command";
111
+ }
112
+ }
113
+ if (!isInt(timeout) || timeout < DECLARATION_MIN_TIMEOUT_SECONDS || timeout > DECLARATION_MAX_TIMEOUT_SECONDS) {
114
+ return "malformed_command";
115
+ }
116
+ if (!isInt(position) || position !== index)
117
+ return "position_mismatch";
118
+ if (typeof identity !== "string" || identity !== declaredCommandIdentity(index, { name, argv: argv, timeout_seconds: timeout })) {
119
+ return "identity_mismatch";
120
+ }
121
+ return { identity, position, name, argv: [...argv], timeoutSeconds: timeout };
122
+ }
123
+ /**
124
+ * Read and validate a claimed job's `required_verification` declaration,
125
+ * all-or-nothing. See {@link RequiredVerificationRead}.
126
+ */
127
+ export function readRequiredVerification(payload) {
128
+ if (!payload || !Object.prototype.hasOwnProperty.call(payload, REQUIRED_VERIFICATION_PAYLOAD_KEY)) {
129
+ return { kind: "none" };
130
+ }
131
+ const raw = payload[REQUIRED_VERIFICATION_PAYLOAD_KEY];
132
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
133
+ return { kind: "refused", reason: "not_an_object", provenance: null, digest: null };
134
+ }
135
+ const d = raw;
136
+ const digest = typeof d.digest === "string" && DIGEST_RE.test(d.digest) ? d.digest : null;
137
+ const provenance = d.provenance === "declared" || d.provenance === "declared_empty" || d.provenance === "absent" || d.provenance === "refused"
138
+ ? d.provenance
139
+ : null;
140
+ const refuse = (reason) => ({
141
+ kind: "refused",
142
+ reason,
143
+ provenance,
144
+ digest,
145
+ });
146
+ if (d.schema_version !== REQUIRED_VERIFICATION_SCHEMA_VERSION)
147
+ return refuse("unsupported_schema_version");
148
+ if (Object.keys(d).some((k) => !DECLARATION_KEYS.has(k)))
149
+ return refuse("unsupported_schema_version");
150
+ if (provenance === null)
151
+ return refuse("unknown_provenance");
152
+ if (digest === null)
153
+ return refuse("malformed_digest");
154
+ if (provenance === "refused")
155
+ return refuse("server_refused");
156
+ if (Object.prototype.hasOwnProperty.call(d, "refusal_reason"))
157
+ return refuse("unknown_provenance");
158
+ const planVersion = d.plan_version;
159
+ const planHash = d.plan_hash;
160
+ if (!(planVersion === null || (isInt(planVersion) && planVersion >= 0)))
161
+ return refuse("malformed_plan_identity");
162
+ if (!(planHash === null || (typeof planHash === "string" && planHash.length > 0))) {
163
+ return refuse("malformed_plan_identity");
164
+ }
165
+ if (!Array.isArray(d.commands) || d.commands.length > DECLARATION_MAX_COMMANDS)
166
+ return refuse("cardinality_mismatch");
167
+ const commands = d.commands;
168
+ if ((provenance === "declared") !== commands.length > 0)
169
+ return refuse("cardinality_mismatch");
170
+ const checks = [];
171
+ const seen = new Set();
172
+ for (let i = 0; i < commands.length; i++) {
173
+ const parsed = validCommand(commands[i], i);
174
+ if (typeof parsed === "string")
175
+ return refuse(parsed);
176
+ if (seen.has(parsed.identity))
177
+ return refuse("duplicate_identity");
178
+ seen.add(parsed.identity);
179
+ checks.push(parsed);
180
+ }
181
+ if (computeDeclarationDigest(d) !== digest)
182
+ return refuse("digest_mismatch");
183
+ return {
184
+ kind: "ok",
185
+ declaration: {
186
+ provenance,
187
+ digest,
188
+ planVersion: planVersion,
189
+ planHash: planHash,
190
+ checks,
191
+ },
192
+ };
193
+ }
194
+ /**
195
+ * The declared checks of a VALIDATED declaration, or `[]`. Never a partial
196
+ * list: a declaration that fails validation has no runnable checks at all, and
197
+ * the covered lane refuses it before launch (see {@link readRequiredVerification}).
198
+ */
199
+ export function readDeclaredChecks(payload) {
200
+ const read = readRequiredVerification(payload);
201
+ return read.kind === "ok" ? read.declaration.checks : [];
202
+ }
203
+ export function finalizationObligations(branch, baseBranch) {
204
+ return {
205
+ push: { remote: "origin", ref: `refs/heads/${branch}` },
206
+ prCreate: { head: branch, base: baseBranch },
207
+ };
208
+ }
209
+ /**
210
+ * Map a finalization outcome onto the contract verdict plus which of the two
211
+ * obligations were independently satisfied. The mapping reads the executor's
212
+ * own finalization error kinds; it never trusts worker text.
213
+ */
214
+ export function finalizationFacts(outcome) {
215
+ if (outcome === null)
216
+ return { verdict: "unknown", pushSatisfied: false, prSatisfied: false };
217
+ if (outcome.ok)
218
+ return { verdict: "ok", pushSatisfied: true, prSatisfied: true };
219
+ switch (outcome.errorKind) {
220
+ case "WorkerFinalizationPrNotAttached":
221
+ case "WorkerFinalizationPrBaseMismatch":
222
+ return { verdict: "unmet", pushSatisfied: true, prSatisfied: false };
223
+ case "WorkerFinalizationMissingRemoteBranchAndPr":
224
+ case "WorkerFinalizationSavedButUnfinalized":
225
+ return { verdict: "unmet", pushSatisfied: false, prSatisfied: false };
226
+ default:
227
+ return { verdict: "unknown", pushSatisfied: false, prSatisfied: false };
228
+ }
229
+ }
230
+ /**
231
+ * Read the server-stamped completion binding for a claimed job.
232
+ *
233
+ * The job id, run, child and attempt come from the CLAIM (the server minted
234
+ * them); the park generation and originating job come from the payload's
235
+ * `completion_binding`, which only the reconciler writes. Nothing here is taken
236
+ * from worker output.
237
+ */
238
+ export function readCompletionBinding(job, dispatchHeadSha) {
239
+ const raw = job.payload?.["completion_binding"];
240
+ const b = raw && typeof raw === "object" ? raw : {};
241
+ const intOrNull = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null;
242
+ const spentSeconds = intOrNull(b["spent_allowance_seconds"]) ?? 0;
243
+ return {
244
+ binding: {
245
+ job_id: job.id,
246
+ epic_run_id: job.epic_run_id ?? null,
247
+ ticket_key: job.ticket_key ?? null,
248
+ attempt: job.attempt_count,
249
+ park_generation: intOrNull(b["park_generation"]),
250
+ originating_job_id: intOrNull(b["originating_job_id"]),
251
+ dispatch_head_sha: dispatchHeadSha,
252
+ final_head_sha: null,
253
+ },
254
+ spentAllowanceMs: spentSeconds * 1000,
255
+ };
256
+ }
257
+ /**
258
+ * Read the server-owned optional-watcher declaration. Only an exact kind +
259
+ * input digest match makes a watcher optional; worker prose or a label the
260
+ * worker assigns can never do it.
261
+ */
262
+ export function readOptionalWatchers(payload) {
263
+ const policy = payload?.["completion_policy"];
264
+ const raw = policy && typeof policy === "object" ? policy["optional_watchers"] : undefined;
265
+ if (!Array.isArray(raw))
266
+ return [];
267
+ const out = [];
268
+ for (const entry of raw.slice(0, 16)) {
269
+ if (!entry || typeof entry !== "object")
270
+ continue;
271
+ const e = entry;
272
+ if ((e["kind"] === "monitor" || e["kind"] === "bash_bg") && typeof e["input_digest"] === "string" && /^[0-9a-f]{64}$/.test(e["input_digest"])) {
273
+ out.push({ kind: e["kind"], input_digest: e["input_digest"] });
274
+ }
275
+ }
276
+ return out;
277
+ }
278
+ /** Terminal `error_kind` for each completion cause. Executor-owned vocabulary. */
279
+ export const COMPLETION_CAUSE_ERROR_KINDS = {
280
+ required_work_incomplete: "ContractError.RequiredWorkIncomplete",
281
+ required_check_failed: "ContractError.RequiredCheckFailed",
282
+ permission_stop: "ContractError.PermissionStop",
283
+ unsupported_completion_evidence: "ContractError.CompletionEvidenceUnsupported",
284
+ cleanup_unconfirmed: "ContractError.CleanupUnconfirmed",
285
+ verification_runner_unavailable: "ContractError.VerificationRunnerUnavailable",
286
+ };
@@ -17,7 +17,7 @@
17
17
  * never resolves or stores it, and `ANTHROPIC_API_KEY` remains absolutely denied
18
18
  * to a worker (`executor/env.ts`).
19
19
  */
20
- import { resolveBapiCredentials } from "../credential-store.js";
20
+ import { readStoredBinding, resolveBapiCredentials } from "../credential-store.js";
21
21
  import { normalizeBaseUrlCandidate } from "../base-url.js";
22
22
  /**
23
23
  * The documented production Bridge API base URL — the value an operator pastes
@@ -79,6 +79,24 @@ export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl)
79
79
  error: `could not resolve Bridge API credentials for repo '${trimmed}' (${result.kind})`,
80
80
  };
81
81
  }
82
+ // BAPI-1260: a stored key that guided setup approved for ONE endpoint is never
83
+ // sent to another. A restarted executor re-reads the binding here, so a unit
84
+ // whose --base-url drifted from the approved target refuses before its first
85
+ // authenticated request. An unbound (legacy) entry keeps today's behavior —
86
+ // the explicit-target requirement above still applies to it. This agrees
87
+ // with the production epic launch (BAPI-1272), which also accepts an unbound
88
+ // stored key at production and refuses one bound to another endpoint.
89
+ if (result.credentials.source === "file") {
90
+ const bound = await readStoredBinding(`bapi:${trimmed}`, storeDeps);
91
+ if (bound !== null && bound !== url.baseUrl) {
92
+ return {
93
+ ok: false,
94
+ error: `the stored credential for repo '${trimmed}' is approved for a different Bridge API ` +
95
+ "endpoint than this executor's base URL; it was not sent. Re-run guided setup " +
96
+ "(install conductor) to re-bind it, or correct --base-url.",
97
+ };
98
+ }
99
+ }
82
100
  return {
83
101
  ok: true,
84
102
  apiKey: result.credentials.apiKey,
@@ -25,6 +25,43 @@ const MAX_COMMAND_BUFFER = 10 * 1024 * 1024;
25
25
  * evaluation instead of stalling.
26
26
  */
27
27
  const EXECUTOR_HTTP_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * Wrap a real child stdin as the fakeable {@link OwnedStdin} (BAPI-1301, D4a).
30
+ *
31
+ * The permanent no-op `error` listener is the load-bearing line. A child that
32
+ * exits while its stdin is held open turns the next write (or the final `end()`)
33
+ * into an `EPIPE` emitted as an `error` event, and an `error` event with no
34
+ * listener is an uncaught exception that would crash the executor and every job
35
+ * it is supervising. With the listener attached the failure is still observable
36
+ * — `writeStdinBounded` / `closeStdinBounded` register their own one-shot
37
+ * `error` listener and report `write_error` — but it can never escape.
38
+ */
39
+ function wrapChildStdin(stream) {
40
+ if (!stream)
41
+ return null;
42
+ stream.setDefaultEncoding("utf8");
43
+ stream.on("error", () => {
44
+ /* observed by the bounded write/close helpers; never uncaught */
45
+ });
46
+ return {
47
+ write: (data) => stream.write(data),
48
+ once: (event, listener) => {
49
+ stream.once(event, listener);
50
+ },
51
+ off: (event, listener) => {
52
+ stream.off(event, listener);
53
+ },
54
+ end: () => {
55
+ stream.end();
56
+ },
57
+ get writableEnded() {
58
+ return stream.writableEnded;
59
+ },
60
+ get destroyed() {
61
+ return stream.destroyed;
62
+ },
63
+ };
64
+ }
28
65
  export function createDefaultExecutorDeps() {
29
66
  return {
30
67
  async runCommand(file, args, options) {
@@ -46,24 +83,50 @@ export function createDefaultExecutorDeps() {
46
83
  }
47
84
  },
48
85
  spawnProcess(file, args, options) {
86
+ // BAPI-1301 (D4a): stdin is piped ONLY when the caller selected it. Every
87
+ // legacy spawn keeps `"ignore"`, byte-for-byte the pre-BAPI-1301 stdio.
88
+ const pipeStdin = options.stdin === "pipe";
49
89
  const child = spawn(file, args, {
50
90
  cwd: options.cwd,
51
91
  env: options.env,
52
- stdio: ["ignore", "pipe", "pipe"],
92
+ stdio: [pipeStdin ? "pipe" : "ignore", "pipe", "pipe"],
53
93
  shell: false,
54
94
  });
55
95
  child.stdout?.setEncoding("utf8");
56
96
  child.stderr?.setEncoding("utf8");
97
+ const stdin = pipeStdin ? wrapChildStdin(child.stdin) : null;
98
+ // Recorded on the first `exit`, so `exited()` resolves even when it is
99
+ // asked after the event already fired.
100
+ let exitInfo = null;
101
+ const exitWaiters = [];
102
+ const settleExit = (info) => {
103
+ if (exitInfo !== null)
104
+ return;
105
+ exitInfo = info;
106
+ for (const waiter of exitWaiters.splice(0))
107
+ waiter(info);
108
+ };
109
+ child.on("exit", (code, signal) => settleExit({ exitCode: code, signal }));
110
+ child.on("error", () => settleExit({ exitCode: null, signal: null }));
57
111
  return {
58
112
  pid: child.pid,
59
113
  stdout: child.stdout ?? null,
60
114
  stderr: child.stderr ?? null,
115
+ ...(pipeStdin ? { stdin } : {}),
61
116
  wait() {
62
117
  return new Promise((resolve) => {
63
118
  child.on("close", (code, signal) => resolve({ exitCode: code, signal }));
64
119
  child.on("error", () => resolve({ exitCode: null, signal: null }));
65
120
  });
66
121
  },
122
+ exited() {
123
+ return new Promise((resolve) => {
124
+ if (exitInfo !== null)
125
+ resolve(exitInfo);
126
+ else
127
+ exitWaiters.push(resolve);
128
+ });
129
+ },
67
130
  kill(signal) {
68
131
  try {
69
132
  child.kill(signal);
@@ -128,6 +191,7 @@ export function createDefaultExecutorDeps() {
128
191
  env: process.env,
129
192
  cwd: process.cwd(),
130
193
  platform: process.platform,
194
+ arch: process.arch,
131
195
  homedir: os.homedir,
132
196
  async fetch(url, init) {
133
197
  const g = globalThis;
@@ -119,6 +119,12 @@ const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
119
119
  */
120
120
  export const EXPLICIT_DENY_KEYS = [
121
121
  "BRIDGE_MCP_PROFILE",
122
+ // BAPI-1301: the per-job ownership tag. The executor injects a fresh,
123
+ // unpredictable value AFTER this builder and the adapter have run
124
+ // (`process-containment.ts`), so an ambient value from the shell that
125
+ // launched the executor must never reach a worker: whoever chose the tag
126
+ // could make an unrelated process look job-owned to the cleanup sweep.
127
+ "BRIDGE_EXECUTOR_JOB_TAG",
122
128
  EPIC_IMPLEMENTER_NODE_PATH_ENV,
123
129
  // BAPI-1190: the legacy spelling stays denied so a stale ambient value from an
124
130
  // older package can never reach a worker during a version-skew window.
@@ -49,6 +49,14 @@ export const PUSH_DETECT_INTERVAL_MS = 5_000;
49
49
  */
50
50
  export async function runHeartbeatLoop(params) {
51
51
  const { job, httpClient, options, deps, ownership, proc, observation } = params;
52
+ // BAPI-1301: every signal this loop sends goes through here, so a job-level
53
+ // phase host can withhold it once no owned child is live.
54
+ const signalTarget = () => params.currentChild ? params.currentChild() : proc;
55
+ const killCurrent = (reason) => {
56
+ const target = signalTarget();
57
+ if (target)
58
+ killOwnedProcess(target, reason);
59
+ };
52
60
  const pushDetectIntervalMs = params.pushDetectIntervalMs ?? PUSH_DETECT_INTERVAL_MS;
53
61
  // Baseline for push detection. `undefined` means "not yet sampled": the FIRST
54
62
  // successful sample only establishes the baseline and never itself triggers a
@@ -65,6 +73,7 @@ export async function runHeartbeatLoop(params) {
65
73
  const git = await params.collectTelemetry();
66
74
  observation.setGitTelemetry(git);
67
75
  const residue = observation.snapshot();
76
+ const ownershipContext = params.ownershipContext?.();
68
77
  const result = normalizeHeartbeatResult(await httpClient.heartbeat(job, {
69
78
  // Unconditionally the LOCAL head — never the remote marker. This is what
70
79
  // preserves the BAPI-704 semantic that a zero-commit job reports the base
@@ -72,11 +81,12 @@ export async function runHeartbeatLoop(params) {
72
81
  local_commit_count: git.local_commit_count,
73
82
  last_commit_sha: git.last_commit_sha,
74
83
  telemetry: residue,
84
+ ...(ownershipContext !== undefined ? { ownership_context: ownershipContext } : {}),
75
85
  }));
76
86
  // Checked BEFORE the outcome switch: a stop request is authoritative even
77
87
  // when it rides along with a non-`updated` outcome.
78
88
  if (result.stop_requested === true) {
79
- killOwnedProcess(proc, "server_stop");
89
+ killCurrent("server_stop");
80
90
  return { kind: "server_stop" };
81
91
  }
82
92
  if (result.outcome === "auth_fatal") {
@@ -94,7 +104,7 @@ export async function runHeartbeatLoop(params) {
94
104
  }
95
105
  if (result.outcome === "stale_claim") {
96
106
  // Definitive dead claim: kill the worker and stop touching the worktree.
97
- killOwnedProcess(proc, "stale_claim");
107
+ killCurrent("stale_claim");
98
108
  ownership.abandoned = true;
99
109
  ownership.abandonReason = "stale_claim";
100
110
  return { kind: "abandoned" };
@@ -102,7 +112,7 @@ export async function runHeartbeatLoop(params) {
102
112
  // retry_later / fatal_http_error: keep the worker alive within the dead-man
103
113
  // window; SIGKILL + abandon once 2 minutes pass without a successful beat.
104
114
  if (deps.now() - ownership.lastSuccessfulHeartbeatAt >= options.deadmanMs) {
105
- killOwnedProcess(proc, "deadman");
115
+ killCurrent("deadman");
106
116
  ownership.abandoned = true;
107
117
  ownership.abandonReason = "deadman";
108
118
  return { kind: "abandoned" };
@@ -9,6 +9,73 @@
9
9
  */
10
10
  import { RECONCILER_LIVENESS_VALUES } from "./types.js";
11
11
  import { readEpicRunCompletionState, } from "../conductor/bridge-api-client.js";
12
+ /** BAPI-1258 — the server's stable `error_code` for refused capacity evidence. */
13
+ export const UNSUPPORTED_TERMINAL_CAPACITY_ERROR_CODE = "UNSUPPORTED_TERMINAL_CAPACITY";
14
+ /**
15
+ * Whether a `/fail` response refused the capacity evidence it carried: a 422
16
+ * (malformed envelope) or a 409 whose `error_code` is the stable unsupported
17
+ * code. A 409 `STALE_CLAIM` is NOT a refusal — the claim is gone, and resending
18
+ * cannot help. The body is inspected only for that one closed token.
19
+ */
20
+ function isCapacityEvidenceRefusal(status, bodyText) {
21
+ if (status === 422)
22
+ return true;
23
+ if (status !== 409)
24
+ return false;
25
+ return readDetailErrorCode(bodyText) === UNSUPPORTED_TERMINAL_CAPACITY_ERROR_CODE;
26
+ }
27
+ /**
28
+ * BAPI-1301 (D4a) — the server's stable `error_code` when a covered `/complete`
29
+ * carries an envelope that does not prove the attempt finished its required
30
+ * work. The job is still claimed; the executor must report `/fail` (with the
31
+ * same envelope) instead.
32
+ */
33
+ export const COMPLETION_EVIDENCE_REFUSES_SUCCESS_ERROR_CODE = "COMPLETION_EVIDENCE_REFUSES_SUCCESS";
34
+ /**
35
+ * Read the stable `detail.error_code` token from an error body, or null. Only
36
+ * that one closed token is inspected; nothing else in the body is retained.
37
+ */
38
+ function readDetailErrorCode(bodyText) {
39
+ try {
40
+ const parsed = JSON.parse(bodyText);
41
+ const detail = parsed && typeof parsed === "object" ? parsed.detail : undefined;
42
+ if (!detail || typeof detail !== "object")
43
+ return null;
44
+ const code = detail.error_code;
45
+ return typeof code === "string" ? code : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * BAPI-1301 (D4a) — whether a `/complete` response refused SUCCESS for a covered
53
+ * job because its completion evidence cannot support it: a 409 whose
54
+ * `error_code` is exactly {@link COMPLETION_EVIDENCE_REFUSES_SUCCESS_ERROR_CODE}.
55
+ *
56
+ * Mirrors {@link isCapacityEvidenceRefusal}'s shape, but is exported and NOT
57
+ * wired into `mapMutationResponse`: the mutation mapping stays unchanged (a 409
58
+ * still maps to `stale_claim` for every caller that does not ask), and the job
59
+ * runner — which holds the envelope — decides to re-report as `/fail`. A 409
60
+ * `STALE_CLAIM` is never a refusal: the claim is gone and a re-send cannot help.
61
+ */
62
+ export function isCompletionEvidenceRefusal(status, bodyText) {
63
+ if (status !== 409)
64
+ return false;
65
+ return readDetailErrorCode(bodyText) === COMPLETION_EVIDENCE_REFUSES_SUCCESS_ERROR_CODE;
66
+ }
67
+ /**
68
+ * BAPI-1301 (D4a) — normalize a claim response's `required_capabilities`. Only an
69
+ * array of strings is admitted; anything else (absent on an older server, null,
70
+ * malformed) reads as `[]` — "no requirement", which is exactly what an older
71
+ * server's rows mean. A malformed value can never manufacture a requirement the
72
+ * executor would then refuse to run.
73
+ */
74
+ export function normalizeRequiredCapabilities(value) {
75
+ if (!Array.isArray(value))
76
+ return [];
77
+ return value.every((entry) => typeof entry === "string") ? [...value] : [];
78
+ }
12
79
  /** Bound a server-provided diagnostic snippet included in error strings. */
13
80
  function boundedDetail(text) {
14
81
  const trimmed = (text ?? "").trim();
@@ -118,6 +185,22 @@ function toConductorGetFetch(fetchLike) {
118
185
  };
119
186
  };
120
187
  }
188
+ /**
189
+ * BAPI-1301 (D4a) — the completion-contract fields of a terminal report, each
190
+ * present only when set. Omitting an undefined key (rather than sending it as
191
+ * `undefined`/`null`) keeps a legacy terminal body byte-identical, which an
192
+ * older server's strict `extra="forbid"` schema depends on.
193
+ */
194
+ function completionContractFields(report) {
195
+ return {
196
+ ...(report.completion_contract !== undefined
197
+ ? { completion_contract: report.completion_contract }
198
+ : {}),
199
+ ...(report.completion_evidence !== undefined
200
+ ? { completion_evidence: report.completion_evidence }
201
+ : {}),
202
+ };
203
+ }
121
204
  export function createExecutorHttpClient(config) {
122
205
  const base = config.baseUrl.replace(/\/+$/, "") + "/executor/jobs";
123
206
  function keyForRepo(repoName) {
@@ -182,6 +265,12 @@ export function createExecutorHttpClient(config) {
182
265
  if (!job || typeof job !== "object" || typeof job.claim_token !== "string") {
183
266
  return { kind: "fatal", error: "claim returned a malformed job payload" };
184
267
  }
268
+ // BAPI-1301 (D4a): the requirement is normalized in place only when
269
+ // the server sent the key, so an older server's claim keeps its exact
270
+ // shape; a present-but-malformed value reads as "no requirement".
271
+ if ("required_capabilities" in job) {
272
+ job.required_capabilities = normalizeRequiredCapabilities(job.required_capabilities);
273
+ }
185
274
  return { kind: "claimed", job, reconcilerLiveness };
186
275
  }
187
276
  catch {
@@ -218,6 +307,11 @@ export function createExecutorHttpClient(config) {
218
307
  local_commit_count: payload.local_commit_count,
219
308
  last_commit_sha: payload.last_commit_sha,
220
309
  telemetry: payload.telemetry,
310
+ // BAPI-1301 (D4a): the key is added only when present, so a legacy
311
+ // heartbeat body is byte-identical for an older server.
312
+ ...(payload.ownership_context !== undefined
313
+ ? { ownership_context: payload.ownership_context }
314
+ : {}),
221
315
  };
222
316
  const res = await post(`/${job.id}/heartbeat`, body, job.repo_name);
223
317
  status = res.status;
@@ -266,10 +360,11 @@ export function createExecutorHttpClient(config) {
266
360
  local_commit_count: completion.local_commit_count,
267
361
  last_commit_sha: completion.last_commit_sha,
268
362
  telemetry: completion.telemetry,
363
+ ...completionContractFields(completion),
269
364
  };
270
365
  return mutate(`/${job.id}/complete`, body, true, job.repo_name);
271
366
  },
272
- fail(job, failure) {
367
+ async fail(job, failure) {
273
368
  const body = {
274
369
  repo_name: job.repo_name,
275
370
  claim_token: job.claim_token,
@@ -279,7 +374,28 @@ export function createExecutorHttpClient(config) {
279
374
  local_commit_count: failure.local_commit_count,
280
375
  last_commit_sha: failure.last_commit_sha,
281
376
  telemetry: failure.telemetry,
377
+ ...completionContractFields(failure),
282
378
  };
379
+ if (failure.capacity_evidence === undefined) {
380
+ return mutate(`/${job.id}/fail`, body, false, job.repo_name);
381
+ }
382
+ // BAPI-1258 — send the evidence once. If the server refuses it (unsupported
383
+ // protocol, or a malformed envelope), re-send the SAME ordinary failure
384
+ // without it, exactly once, so the job still reaches a terminal state and
385
+ // the original error kind/classification are preserved. Refused evidence
386
+ // never becomes an automatic recovery.
387
+ let first;
388
+ try {
389
+ first = await post(`/${job.id}/fail`, { ...body, capacity_evidence: failure.capacity_evidence }, job.repo_name);
390
+ }
391
+ catch {
392
+ return { outcome: "fatal_http_error" };
393
+ }
394
+ if (!isCapacityEvidenceRefusal(first.status, first.text)) {
395
+ return mapMutationResponse(first.status, first.text, false);
396
+ }
397
+ (config.warn ?? ((message) => console.error(message)))(`executor: server refused terminal capacity evidence for job ${job.id} ` +
398
+ `(HTTP ${first.status}); reporting an ordinary failure without automatic recovery.`);
283
399
  return mutate(`/${job.id}/fail`, body, false, job.repo_name);
284
400
  },
285
401
  /**