@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * BAPI-1426 — the server-confirmed ticket delivery outcome an outer worker reads
3
+ * before it finalizes.
4
+ *
5
+ * A managed ticket (one with an active delivery binding on the server) may end
6
+ * an attempt on purpose without a pull request: its required work is blocked, a
7
+ * required check failed, or its evidence is missing or stale. The worker then
8
+ * pushes a checkpoint and stops. Finalization must not report that as an
9
+ * accidental missing-PR failure, and must never report it as success either.
10
+ *
11
+ * Only the SERVER can say a ticket is managed and what its outcome is. This
12
+ * module makes two bounded, read-only GETs through the executor's own fetch
13
+ * boundary and the shared repo-scoped credential resolver:
14
+ *
15
+ * 1. `GET /ticket-delivery/executions/recover` — is there an active attempt for
16
+ * this ticket and branch? A 404 means "not managed" (legacy behavior).
17
+ * 2. `GET /ticket-delivery/executions/{attempt_id}/readiness?boundary=publish`
18
+ * — the server's current decision for that attempt.
19
+ *
20
+ * It never throws. Any failure is reported as `unavailable`, which finalization
21
+ * treats as "no server confirmation" and so falls through to its existing
22
+ * checks unchanged. An outage is never turned into a failed check. Nothing from
23
+ * the response is kept beyond closed tokens, a full SHA, an identifier, integer
24
+ * step ids, and a PR number; free text from evidence never reaches a message.
25
+ */
26
+ import { buildConductorVcsUrl, resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
27
+ /** Server readiness outcomes (mirror of `ticket_delivery_readiness.py`). */
28
+ export const DELIVERY_OUTCOMES = [
29
+ "blocked",
30
+ "verification_failed",
31
+ "evidence_missing_or_stale",
32
+ "awaiting_post_pr",
33
+ "complete",
34
+ ];
35
+ /** Default bound for each of the two reads. */
36
+ export const DELIVERY_OUTCOME_READ_TIMEOUT_MS = 15_000;
37
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
38
+ const ATTEMPT_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
39
+ const TOKEN_RE = /^[a-z][a-z0-9_]{0,63}$/;
40
+ const MAX_BLOCKERS = 16;
41
+ const UNAVAILABLE_UNKNOWN = { bound: "unknown", unavailable: true };
42
+ const UNAVAILABLE_BOUND = { bound: true, unavailable: true };
43
+ /** One bounded GET. Never throws; never reads the body of a failed response. */
44
+ async function boundedGet(url, headers, deps) {
45
+ const timeoutMs = Math.max(1, deps.timeoutMs ?? DELIVERY_OUTCOME_READ_TIMEOUT_MS);
46
+ const setTimer = deps.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
47
+ const clearTimer = (deps.clearTimer ?? ((h) => clearTimeout(h)));
48
+ let handle;
49
+ const timeout = new Promise((resolve) => {
50
+ handle = setTimer(() => resolve({ kind: "failed" }), timeoutMs);
51
+ });
52
+ const read = (async () => {
53
+ try {
54
+ const res = await deps.fetch(url, { method: "GET", headers });
55
+ if (res.status === 404)
56
+ return { kind: "not_found" };
57
+ if (res.status < 200 || res.status >= 300)
58
+ return { kind: "failed" };
59
+ const text = await res.text();
60
+ return { kind: "ok", body: JSON.parse(text) };
61
+ }
62
+ catch {
63
+ return { kind: "failed" };
64
+ }
65
+ })();
66
+ try {
67
+ return await Promise.race([read, timeout]);
68
+ }
69
+ finally {
70
+ if (handle !== undefined)
71
+ clearTimer(handle);
72
+ }
73
+ }
74
+ function asRecord(value) {
75
+ return value !== null && typeof value === "object" && !Array.isArray(value)
76
+ ? value
77
+ : null;
78
+ }
79
+ /** Reduce the server's blocker list to closed kind tokens and integer step ids. */
80
+ export function summarizeDeliveryBlockers(value) {
81
+ if (!Array.isArray(value))
82
+ return [];
83
+ const out = [];
84
+ for (const entry of value) {
85
+ if (out.length >= MAX_BLOCKERS)
86
+ break;
87
+ const rec = asRecord(entry);
88
+ if (!rec)
89
+ continue;
90
+ const kind = typeof rec.kind === "string" && TOKEN_RE.test(rec.kind) ? rec.kind : "unrecognized";
91
+ const step = rec.step_id;
92
+ out.push(typeof step === "number" && Number.isInteger(step) && step >= 0 && step <= 1_000_000
93
+ ? { kind, step_id: step }
94
+ : { kind });
95
+ }
96
+ return out;
97
+ }
98
+ function isDeliveryOutcomeToken(value) {
99
+ return typeof value === "string" && DELIVERY_OUTCOMES.includes(value);
100
+ }
101
+ /**
102
+ * BAPI-1473: does this server decision show a renewed attempt at `headSha` whose
103
+ * only outstanding work is `remote_ci` deferrals? Requires a bound, available
104
+ * `awaiting_post_pr` decision, the server's remote-CI-only flag, and an OBSERVED
105
+ * head equal to `headSha` (never the preserved fallback). Anything else is false.
106
+ */
107
+ export function deliveryRenewedWithRemoteCiDeferrals(outcome, headSha) {
108
+ if (!outcome || outcome.bound !== true || outcome.unavailable === true)
109
+ return false;
110
+ if (outcome.outcome !== "awaiting_post_pr" || outcome.postPrRemoteCiOnly !== true)
111
+ return false;
112
+ const head = headSha.trim().toLowerCase();
113
+ return FULL_SHA_RE.test(head) && outcome.observedHeadSha === head;
114
+ }
115
+ /**
116
+ * Read the server-confirmed delivery outcome for `job` on `branch`. Never throws.
117
+ * Returns `{ bound: false }` when the job has no ticket or branch, or when the
118
+ * server reports no active attempt (404).
119
+ */
120
+ export async function fetchServerDeliveryOutcome(job, branch, deps) {
121
+ try {
122
+ const ticket = typeof job.ticket_key === "string" ? job.ticket_key.trim() : "";
123
+ const sourceBranch = typeof branch === "string" ? branch.trim() : "";
124
+ if (!ticket || !sourceBranch || !job.repo_name)
125
+ return { bound: false };
126
+ const resolve = deps.resolveAccess ??
127
+ ((repoName) => resolveConductorBridgeApiAccess({ ...(deps.accessDeps ?? {}), repoName }));
128
+ const access = await resolve(job.repo_name);
129
+ if (!access.ok)
130
+ return UNAVAILABLE_UNKNOWN;
131
+ const headers = { "X-API-Key": access.access.apiKey };
132
+ const recoverUrl = new URL(buildConductorVcsUrl(access.access.baseUrl, "/ticket-delivery/executions/recover"));
133
+ recoverUrl.searchParams.set("repo_name", job.repo_name);
134
+ recoverUrl.searchParams.set("ticket_number", ticket);
135
+ recoverUrl.searchParams.set("source_branch", sourceBranch);
136
+ const recovered = await boundedGet(recoverUrl.toString(), headers, deps);
137
+ if (recovered.kind === "not_found")
138
+ return { bound: false };
139
+ if (recovered.kind !== "ok")
140
+ return UNAVAILABLE_UNKNOWN;
141
+ const binding = asRecord(asRecord(recovered.body)?.binding);
142
+ const attemptId = binding?.attempt_id;
143
+ if (typeof attemptId !== "string" || !ATTEMPT_ID_RE.test(attemptId))
144
+ return UNAVAILABLE_BOUND;
145
+ const readinessUrl = new URL(buildConductorVcsUrl(access.access.baseUrl, `/ticket-delivery/executions/${encodeURIComponent(attemptId)}/readiness`));
146
+ readinessUrl.searchParams.set("repo_name", job.repo_name);
147
+ readinessUrl.searchParams.set("boundary", "publish");
148
+ const read = await boundedGet(readinessUrl.toString(), headers, deps);
149
+ if (read.kind !== "ok")
150
+ return UNAVAILABLE_BOUND;
151
+ const body = asRecord(read.body);
152
+ const readiness = asRecord(body?.readiness);
153
+ if (!readiness || !isDeliveryOutcomeToken(readiness.outcome))
154
+ return UNAVAILABLE_BOUND;
155
+ // `preserved_head_sha` is the observed head when the server observed one, but
156
+ // falls back to the binding's stored head otherwise, so it is NOT proof that
157
+ // the decision applies to the current head. `observed_head_sha` is.
158
+ const rawHead = typeof readiness.preserved_head_sha === "string" ? readiness.preserved_head_sha.trim().toLowerCase() : "";
159
+ const snapshot = asRecord(readiness.snapshot);
160
+ const rawObserved = typeof snapshot?.observed_head_sha === "string" ? snapshot.observed_head_sha.trim().toLowerCase() : "";
161
+ const freshBinding = asRecord(body?.binding) ?? binding;
162
+ const rawPr = freshBinding?.pr_number;
163
+ return {
164
+ bound: true,
165
+ attemptId,
166
+ outcome: readiness.outcome,
167
+ preservedHeadSha: FULL_SHA_RE.test(rawHead) ? rawHead : null,
168
+ blockers: summarizeDeliveryBlockers(readiness.blockers),
169
+ prNumber: typeof rawPr === "number" && Number.isInteger(rawPr) && rawPr > 0 ? rawPr : null,
170
+ // Additive (BAPI-1473): present only when the server supplied a usable value,
171
+ // so a response without them parses exactly as it did before.
172
+ ...(FULL_SHA_RE.test(rawObserved) ? { observedHeadSha: rawObserved } : {}),
173
+ ...(readiness.post_pr_remote_ci_only === true ? { postPrRemoteCiOnly: true } : {}),
174
+ };
175
+ }
176
+ catch {
177
+ return UNAVAILABLE_UNKNOWN;
178
+ }
179
+ }
@@ -230,5 +230,8 @@ export function createDefaultExecutorDeps() {
230
230
  argv1: process.argv[1],
231
231
  fileExists: existsSync,
232
232
  }),
233
+ // BAPI-1479: `workerResources` is deliberately NOT built here. Integration
234
+ // tests spread these real deps, and a real provisioning seam would reach the
235
+ // host PostgreSQL from them. The executor CLI attaches it (`cli.ts`).
233
236
  };
234
237
  }
@@ -48,6 +48,21 @@
48
48
  * value already in the environment WINS over a `.env` entry, which is what makes
49
49
  * the sentinel effective even if a file ever survived the strip.
50
50
  *
51
+ * BAPI-1479: "secret-free" means no AMBIENT credential reaches a worker. It does
52
+ * not mean the worker carries no key whose name looks secret. Two kinds of value
53
+ * are injected by the executor from its own constants and explicit job state,
54
+ * after the allowlist has filtered the parent environment:
55
+ *
56
+ * - `CI_WORKER_ENV_CONSTANTS` — CI's throwaway, non-secret test values (the four
57
+ * `BGPT_*` keys and `ENVIRONMENT=LOCAL`), so a worker can import `main` the way
58
+ * CI does. The allowlist still refuses these names from `parentEnv`.
59
+ * - a DISPOSABLE job database (`ExecutorWorkerEnvOptions.workerDatabase`), which
60
+ * the job runner provisions per job and drops afterwards. When it is present
61
+ * the four sentinel keys name that database instead and the loopback role keys
62
+ * (including an empty `POSTGRES_PASSWORD`) are added; when it is absent the
63
+ * sentinel stays exactly as BAPI-1019 set it. The worker role is refused on
64
+ * `bridgeapi`, so the job database is never the operational one.
65
+ *
51
66
  * The `mcp-invoke` shim (a separate process) resolves `BAPI_API_KEY` itself at
52
67
  * call time from the operator's real user-scoped credential store, exactly as it
53
68
  * did before BAPI-731, so this worker environment never carries it. The model is
@@ -57,7 +72,9 @@
57
72
  import { PR_BASE_BRANCH_ENV_VAR, WORKER_BRANCH_ENV_VAR } from "../pr-base-contract.js";
58
73
  import { DECLARED_TOUCHED_FILES_ENV, normalizeDeclaredTouchedFiles, } from "../conductor/file-scope-guard.js";
59
74
  import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
75
+ import { delimiter as PLATFORM_PATH_DELIMITER } from "node:path";
60
76
  import { EPIC_IMPLEMENTER_ENV_PREFIX, EPIC_IMPLEMENTER_NODE_PATH_ENV, LEGACY_EPIC_IMPLEMENTER_ENV_PREFIX, LEGACY_EPIC_IMPLEMENTER_NODE_PATH_ENV, } from "../epic-implementer-env.js";
77
+ import { buildWorkerDatabaseEnv } from "./worker-database-target.js";
61
78
  /** Non-secret operational keys forwarded to the worker when present. */
62
79
  const ALLOWED_ENV_KEYS = [
63
80
  "PATH",
@@ -103,6 +120,29 @@ export const WORKER_SENTINEL_DATABASE_ENV_KEYS = [
103
120
  "POSTGRES_DB",
104
121
  "PGDATABASE",
105
122
  ];
123
+ /**
124
+ * CI's non-secret test values, injected into EVERY worker environment (BAPI-1479).
125
+ *
126
+ * These are the exact literals the Python lane of
127
+ * `.github/workflows/epic-implementer-ci.yml` sets. They are throwaway CI values,
128
+ * deliberately NOT secrets, and they encrypt nothing real — `BGPT_ENCRYPTION_KEY`
129
+ * only has to be a syntactically valid Fernet key so crypto paths import.
130
+ * Without them a worker cannot run any test that imports `main`
131
+ * (`KeyError: BGPT_SESSION_SECRET`), so it could not run the checks CI runs.
132
+ *
133
+ * Executor-INJECTED, never inherited: the builder assigns them after the
134
+ * allowlist loop from this map, so an operator's ambient value (possibly a real
135
+ * secret) can never reach a worker under these names. The allowlist is untouched
136
+ * — `isExecutorEnvKeyAllowed("BGPT_SESSION_SECRET")` is still `false`, and none
137
+ * of these keys is in `ALLOWED_ENV_KEYS`.
138
+ */
139
+ export const CI_WORKER_ENV_CONSTANTS = Object.freeze({
140
+ BGPT_ENCRYPTION_KEY: "YmFwaTYxMC1jaS10ZXN0LWtleS1ub3QtYS1zZWNyZXQ=",
141
+ BGPT_DEMO_SECRET: "test-demo-secret",
142
+ BGPT_SESSION_SECRET: "test-session-secret",
143
+ BGPT_INVITE_SECRET: "test-invite-secret",
144
+ ENVIRONMENT: "LOCAL",
145
+ });
106
146
  /** Substrings that mark a key as secret-bearing (case-insensitive). */
107
147
  const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
108
148
  /**
@@ -148,6 +188,16 @@ export const EXPLICIT_DENY_KEYS = [
148
188
  "OPENAI_API_KEY",
149
189
  "GITHUB_TOKEN",
150
190
  "GH_TOKEN",
191
+ // BAPI-1452: the canonical local merge credential. The executor PROCESS keeps
192
+ // it — the local merge job authenticates with it — so it is deliberately NOT
193
+ // on `EXECUTOR_PROCESS_GITHUB_CREDENTIAL_DENY_KEYS` below. A worker never
194
+ // needs it, and denying it here keeps a merge-capable token out of every
195
+ // agent session even if a future edit widens the allowlist.
196
+ "GITHUB_MERGE_TOKEN",
197
+ // BAPI-1461: its deprecated alias and the machine-user key, denied explicitly
198
+ // for the same reason. The executor process keeps both; a worker never does.
199
+ "BAPI_LOCAL_MERGE_TOKEN",
200
+ "BAPI_MACHINE_MERGE_TOKEN",
151
201
  "CLAUDE_CONFIG_DIR",
152
202
  "XDG_CONFIG_HOME",
153
203
  // BAPI-844: the index-scope declaration follows the BAPI_BASE_BRANCH rule —
@@ -288,6 +338,27 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
288
338
  env.POSTGRES_DATABASE = WORKER_SENTINEL_DATABASE_NAME;
289
339
  env.POSTGRES_DB = WORKER_SENTINEL_DATABASE_NAME;
290
340
  env.PGDATABASE = WORKER_SENTINEL_DATABASE_NAME;
341
+ // BAPI-1479 — a provisioned job database replaces the four sentinel keys
342
+ // TOGETHER and adds the loopback role keys. Validated before anything is set, so
343
+ // an invalid name throws rather than leaving a half-pointed environment.
344
+ if (options.workerDatabase !== undefined) {
345
+ Object.assign(env, buildWorkerDatabaseEnv(options.workerDatabase));
346
+ }
347
+ // BAPI-1479 — CI's throwaway test values, from constants and never from
348
+ // `parentEnv`, for every job type and every adapter.
349
+ Object.assign(env, CI_WORKER_ENV_CONSTANTS);
350
+ // BAPI-1479 — the own-build launcher directory goes FIRST on PATH, from the
351
+ // explicit option only. The inherited PATH follows it unchanged; an empty or
352
+ // absent inherited PATH adds no empty component (an empty component means
353
+ // "the current directory" to a shell, which a worktree must not be).
354
+ if (typeof options.workerLauncherDirectory === "string" &&
355
+ options.workerLauncherDirectory.length > 0) {
356
+ const inherited = env.PATH;
357
+ env.PATH =
358
+ typeof inherited === "string" && inherited.length > 0
359
+ ? `${options.workerLauncherDirectory}${PLATFORM_PATH_DELIMITER}${inherited}`
360
+ : options.workerLauncherDirectory;
361
+ }
291
362
  if (options.correctionIntentVersion !== undefined) {
292
363
  if (options.correctionIntentVersion !== 1)
293
364
  throw new Error("unsupported correction intent version");
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The closed `telemetry.failure_reason_code` vocabulary (BAPI-1453).
3
+ *
4
+ * A failed executor job already reports a structured `error_kind`, which is the
5
+ * ROUTING signal: the server's terminal disposition and the merge re-fire lane
6
+ * decide on `error_kind` and `classification` alone (R14 rule 3). That kind is
7
+ * deliberately coarse — `MergeRetryable` covers a dozen distinct reasons — so an
8
+ * operator reading `explain_run` could not tell a `gh_pr_view_failed` from a
9
+ * `ci_not_green`. The precise reason existed only in the free-text
10
+ * `error_message`, which `explain_run` never reads.
11
+ *
12
+ * This module names that precise reason as one value from a CLOSED list, so the
13
+ * server can show it without ever reading free text. Two rules keep it safe:
14
+ *
15
+ * - **Display only.** No disposition, retry, park, or rebase decision may read a
16
+ * failure reason code. A row whose code disagrees with its `error_kind` is
17
+ * classified by its `error_kind`.
18
+ * - **Builders own it.** The failure builders in `merge-job.ts` and
19
+ * `job-runner.ts` set it. Observation state never carries it, so a snapshot
20
+ * can never overwrite or invent one.
21
+ *
22
+ * The list is mirrored in `api/library/epic_implementer/failure_reason_codes.py`.
23
+ * A parity test fails when the two drift, so add every new value to both.
24
+ */
25
+ /**
26
+ * Every reason the local merge executor (`conductor/local-merge.ts`) can return
27
+ * on the merge-job path, plus the server's migration-graph refusals that reach
28
+ * the same classifier. The `ci_poll_http_<status>` family is matched by
29
+ * {@link CI_POLL_HTTP_REASON_PATTERN} rather than listed.
30
+ */
31
+ const MERGE_REASON_CODES = [
32
+ "gh_pr_view_timeout",
33
+ "gh_pr_view_failed",
34
+ "gh_pr_view_unparseable",
35
+ "ci_poll_failed",
36
+ "ci_poll_timeout",
37
+ "ci_poll_network",
38
+ "ci_poll_unauthorized",
39
+ "ci_poll_server",
40
+ "ci_poll_http",
41
+ "ci_not_green",
42
+ "gh_merge_timeout",
43
+ "gh_merge_failed",
44
+ "gh_merge_conflict",
45
+ "merge_aborted",
46
+ "head_drift",
47
+ "pr_not_open",
48
+ "ref_mismatch",
49
+ "migration_graph_invalid",
50
+ "migration_graph_unreadable",
51
+ "local_merge_approval_required",
52
+ "local_gh_unavailable",
53
+ "local_merge_credential_missing",
54
+ "machine_merge_credential_missing",
55
+ "local_merge_credential_invalid",
56
+ "machine_merge_credential_invalid",
57
+ "local_merge_credential_unpermitted",
58
+ "machine_merge_credential_unpermitted",
59
+ "local_merge_probe_unavailable",
60
+ "machine_merge_probe_unavailable",
61
+ ];
62
+ /**
63
+ * Merge-flow outcomes the executor itself produces, outside `local-merge.ts`.
64
+ * New contract values introduced by BAPI-1453.
65
+ */
66
+ const EXECUTOR_MERGE_REASON_CODES = [
67
+ /** The claimed job's merge payload failed validation. */
68
+ "merge_payload_invalid",
69
+ /** Bridge API access for the CI re-poll could not be resolved. */
70
+ "merge_access_unavailable",
71
+ /** The whole merge flow exceeded its `timeout_seconds`. */
72
+ "merge_flow_timeout",
73
+ /** The merge flow threw. */
74
+ "merge_flow_error",
75
+ /** The merge flow finished without producing an outcome. */
76
+ "merge_no_outcome",
77
+ /** A failed merge response carried no recognized reason. */
78
+ "merge_failure_unclassified",
79
+ ];
80
+ /**
81
+ * BAPI-1453: the permission probe that runs after a `gh_pr_view_failed` PROVED
82
+ * the merge credential cannot access the repository.
83
+ */
84
+ export const GH_REPO_ACCESS_DENIED = "gh_repo_access_denied";
85
+ /** The four pre-spawn verification refusals (`PreSpawnVerificationReason`). */
86
+ const PRE_SPAWN_REASON_CODES = ["dirty", "status-unverifiable", "head-unverifiable", "head-mismatch"];
87
+ /**
88
+ * BAPI-1530: display-only categories for a pre-worker worktree failure. They
89
+ * describe a structured outcome and are never read by any disposition decision.
90
+ */
91
+ const WORKTREE_REASON_CODES = ["worktree_stale_branch", "worktree_lineage_refused", "worktree_fetch_failed"];
92
+ /** Every fixed failure reason code. */
93
+ export const FAILURE_REASON_CODES = [
94
+ ...MERGE_REASON_CODES,
95
+ ...EXECUTOR_MERGE_REASON_CODES,
96
+ GH_REPO_ACCESS_DENIED,
97
+ ...PRE_SPAWN_REASON_CODES,
98
+ ...WORKTREE_REASON_CODES,
99
+ ];
100
+ /**
101
+ * The bounded `ci_poll_http_<status>` family. Anchored, exactly three digits,
102
+ * matched against the WHOLE string: a trailing newline, an extra suffix, a case
103
+ * change, or any other `ci_poll_*` spelling is rejected. Mirrored byte for byte
104
+ * in the Python module.
105
+ */
106
+ export const CI_POLL_HTTP_REASON_PATTERN = /^ci_poll_http_[0-9]{3}$/;
107
+ /** Longest accepted code. Mirrors the Python bound. */
108
+ export const MAX_FAILURE_REASON_CODE_CHARS = 64;
109
+ const FIXED_CODES = new Set(FAILURE_REASON_CODES);
110
+ /** True only for a value in the closed vocabulary. */
111
+ export function isFailureReasonCode(value) {
112
+ if (typeof value !== "string" || value.length > MAX_FAILURE_REASON_CODE_CHARS)
113
+ return false;
114
+ return FIXED_CODES.has(value) || CI_POLL_HTTP_REASON_PATTERN.test(value);
115
+ }
116
+ /**
117
+ * Narrow a known-good literal to {@link FailureReasonCode}. Throws on a value
118
+ * outside the vocabulary, so a misspelled builder code fails its first test
119
+ * rather than reaching the server as a silently dropped `null`.
120
+ */
121
+ export function failureReasonCode(value) {
122
+ if (!isFailureReasonCode(value)) {
123
+ throw new Error(`not a failure reason code: ${value}`);
124
+ }
125
+ return value;
126
+ }
@@ -197,3 +197,41 @@ export async function runHeartbeatLoop(params) {
197
197
  }
198
198
  return ownership.abandoned ? { kind: "abandoned" } : { kind: "done" };
199
199
  }
200
+ /**
201
+ * Send ONE awaited heartbeat carrying an ownership context, through the same
202
+ * transport, response interpretation, and ownership bookkeeping the periodic loop
203
+ * uses (BAPI-1504). The covered runner calls it BEFORE spawning a worker, with
204
+ * `worker: null`, so a cleanup-capable row's stored ownership is non-null whenever
205
+ * a worker may exist. It is only ever called while no periodic loop is running, so
206
+ * the two can never overlap or arrive out of order.
207
+ *
208
+ * `stale_claim` marks the claim abandoned, exactly like the loop. A stop request
209
+ * and an auth failure are reported to the caller without touching ownership; the
210
+ * caller owns what happens next. It never signals any process.
211
+ */
212
+ export async function sendImmediateHeartbeat(params) {
213
+ const { job, httpClient, deps, ownership, observation } = params;
214
+ const git = await params.collectTelemetry();
215
+ observation.setGitTelemetry(git);
216
+ const result = normalizeHeartbeatResult(await httpClient.heartbeat(job, {
217
+ local_commit_count: git.local_commit_count,
218
+ last_commit_sha: git.last_commit_sha,
219
+ telemetry: observation.snapshot(),
220
+ ownership_context: params.ownershipContext,
221
+ }));
222
+ if (result.stop_requested === true)
223
+ return { kind: "server_stop" };
224
+ if (result.outcome === "auth_fatal") {
225
+ return { kind: "auth_fatal", httpStatus: result.authHttpStatus ?? 401 };
226
+ }
227
+ if (result.outcome === "updated") {
228
+ ownership.lastSuccessfulHeartbeatAt = deps.now();
229
+ return { kind: "accepted" };
230
+ }
231
+ if (result.outcome === "stale_claim") {
232
+ ownership.abandoned = true;
233
+ ownership.abandonReason = "stale_claim";
234
+ return { kind: "stale_claim" };
235
+ }
236
+ return { kind: "transient" };
237
+ }
@@ -8,6 +8,7 @@
8
8
  * logged and never included in any error string.
9
9
  */
10
10
  import { RECONCILER_LIVENESS_VALUES } from "./types.js";
11
+ import { EXECUTOR_REPORTABLE_END_CAUSE_VALUES, } from "./completion-contract.js";
11
12
  import { readEpicRunCompletionState, } from "../conductor/bridge-api-client.js";
12
13
  /** BAPI-1258 — the server's stable `error_code` for refused capacity evidence. */
13
14
  export const UNSUPPORTED_TERMINAL_CAPACITY_ERROR_CODE = "UNSUPPORTED_TERMINAL_CAPACITY";
@@ -92,6 +93,41 @@ export function normalizePriorAttemptHeadSha(value, onMalformed) {
92
93
  onMalformed();
93
94
  return null;
94
95
  }
96
+ /**
97
+ * BAPI-1421 (F4) — normalize a claim's `end_cause_capability` to the recognized
98
+ * executor-reportable values. Absent, null, or malformed reads as an empty list
99
+ * (an older server), and an unknown string is dropped rather than trusted.
100
+ */
101
+ export function normalizeEndCauseCapability(value) {
102
+ if (!Array.isArray(value))
103
+ return [];
104
+ const allowed = new Set(EXECUTOR_REPORTABLE_END_CAUSE_VALUES);
105
+ const out = [];
106
+ for (const entry of value) {
107
+ if (allowed.has(entry) && !out.includes(entry)) {
108
+ out.push(entry);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /**
114
+ * BAPI-1421 (F4) — the completion envelope a claim may carry. A copy that keeps
115
+ * `end_cause` only when the claim's advertised list holds that exact value; an
116
+ * unadvertised cause is removed, never sent as null or rewritten. Every other
117
+ * evidence field is unchanged. An older server's strict `extra="forbid"`
118
+ * envelope would 422 an unknown key, so this is what keeps a newer executor
119
+ * wire-compatible with it.
120
+ */
121
+ export function envelopeForClaim(job, envelope) {
122
+ if (!("end_cause" in envelope))
123
+ return envelope;
124
+ const allowed = normalizeEndCauseCapability(job.end_cause_capability);
125
+ if (envelope.end_cause !== undefined && allowed.includes(envelope.end_cause)) {
126
+ return envelope;
127
+ }
128
+ const { end_cause: _omitted, ...rest } = envelope;
129
+ return rest;
130
+ }
95
131
  /** Bound a server-provided diagnostic snippet included in error strings. */
96
132
  function boundedDetail(text) {
97
133
  const trimmed = (text ?? "").trim();
@@ -181,6 +217,25 @@ function mapMutationResponse(status, bodyText, allowInvalidResult) {
181
217
  return { outcome: "auth_fatal", authHttpStatus: status };
182
218
  return { outcome: "fatal_http_error" };
183
219
  }
220
+ /**
221
+ * The closed `error_code` of a cleanup-report 409 refusal, or `null` for anything
222
+ * else. Reads only the code; the body is never retained (BAPI-1504).
223
+ */
224
+ function parseCleanupRefusalCode(bodyText) {
225
+ try {
226
+ const parsed = JSON.parse(bodyText);
227
+ if (typeof parsed !== "object" || parsed === null)
228
+ return null;
229
+ const detail = parsed.detail;
230
+ if (typeof detail !== "object" || detail === null)
231
+ return null;
232
+ const code = detail.error_code;
233
+ return code === "CLEANUP_REPORT_NOT_APPLICABLE" || code === "CLEANUP_REPORT_JOB_RUNNING" ? code : null;
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
184
239
  /**
185
240
  * Adapt the executor's injected `FetchLike` transport to the narrower
186
241
  * `ConductorFetch` shape the shared read-only Bridge API GET helper expects
@@ -207,13 +262,13 @@ function toConductorGetFetch(fetchLike) {
207
262
  * `undefined`/`null`) keeps a legacy terminal body byte-identical, which an
208
263
  * older server's strict `extra="forbid"` schema depends on.
209
264
  */
210
- function completionContractFields(report) {
265
+ function completionContractFields(job, report) {
211
266
  return {
212
267
  ...(report.completion_contract !== undefined
213
268
  ? { completion_contract: report.completion_contract }
214
269
  : {}),
215
270
  ...(report.completion_evidence !== undefined
216
- ? { completion_evidence: report.completion_evidence }
271
+ ? { completion_evidence: envelopeForClaim(job, report.completion_evidence) }
217
272
  : {}),
218
273
  };
219
274
  }
@@ -289,6 +344,9 @@ export function createExecutorHttpClient(config) {
289
344
  }
290
345
  // BAPI-1367: a malformed prior head fails closed to null with one
291
346
  // bounded warning naming only the job, never the raw value or a SHA.
347
+ // BAPI-1421 (F4): only recognized executor-reportable end causes are
348
+ // kept; an older server's absent key becomes an empty list.
349
+ job.end_cause_capability = normalizeEndCauseCapability(job.end_cause_capability);
292
350
  job.prior_attempt_head_sha = normalizePriorAttemptHeadSha(job.prior_attempt_head_sha, () => (config.warn ?? ((message) => console.error(message)))(`executor: ignoring malformed prior attempt head in the claim for job ${String(job.id)}; ` +
293
351
  `treating it as a first dispatch.`));
294
352
  return { kind: "claimed", job, reconcilerLiveness };
@@ -380,6 +438,59 @@ export function createExecutorHttpClient(config) {
380
438
  return "failed";
381
439
  }
382
440
  },
441
+ /**
442
+ * Deliver one cleanup confirmation (BAPI-1504).
443
+ *
444
+ * Only a validated 200 body or an explicit `409 CLEANUP_REPORT_NOT_APPLICABLE`
445
+ * is terminal. Everything else retains the local ownership record. Nothing
446
+ * from the response body, the token, or a raw transport error is returned.
447
+ */
448
+ async reportCleanup(request) {
449
+ let status;
450
+ let text;
451
+ try {
452
+ const res = await post(`/${request.jobId}/cleanup-report`, {
453
+ repo_name: request.repoName,
454
+ ownership_token: request.ownershipToken,
455
+ cleanup_verdict: "confirmed",
456
+ path: request.path,
457
+ }, request.repoName);
458
+ status = res.status;
459
+ text = res.text;
460
+ }
461
+ catch {
462
+ return { kind: "retained", reason: "network_error", retryable: true };
463
+ }
464
+ if (status >= 200 && status < 300) {
465
+ return isOkBody(text)
466
+ ? { kind: "accepted" }
467
+ : { kind: "retained", reason: "malformed_response", retryable: false };
468
+ }
469
+ if (status === 409) {
470
+ const code = parseCleanupRefusalCode(text);
471
+ if (code === "CLEANUP_REPORT_NOT_APPLICABLE")
472
+ return { kind: "not_applicable" };
473
+ if (code === "CLEANUP_REPORT_JOB_RUNNING") {
474
+ return { kind: "retained", reason: "job_running", retryable: true };
475
+ }
476
+ return { kind: "retained", reason: "malformed_response", retryable: false };
477
+ }
478
+ if (status === 401 || status === 403) {
479
+ return { kind: "retained", reason: "unauthorized", retryable: false };
480
+ }
481
+ if (status === 404) {
482
+ // A server that predates the route: keep the record for a later start.
483
+ (config.warn ?? ((message) => console.error(message)))(`cleanup_report_unsupported job=${request.jobId}`);
484
+ return { kind: "retained", reason: "unsupported", retryable: false };
485
+ }
486
+ if (status === 429)
487
+ return { kind: "retained", reason: "rate_limited", retryable: true };
488
+ if (status === 503)
489
+ return { kind: "retained", reason: "retry_later", retryable: true };
490
+ if (status >= 500)
491
+ return { kind: "retained", reason: "server_error", retryable: true };
492
+ return { kind: "retained", reason: "malformed_response", retryable: false };
493
+ },
383
494
  complete(job, completion) {
384
495
  const body = {
385
496
  repo_name: job.repo_name,
@@ -392,7 +503,7 @@ export function createExecutorHttpClient(config) {
392
503
  local_commit_count: completion.local_commit_count,
393
504
  last_commit_sha: completion.last_commit_sha,
394
505
  telemetry: completion.telemetry,
395
- ...completionContractFields(completion),
506
+ ...completionContractFields(job, completion),
396
507
  };
397
508
  return mutate(`/${job.id}/complete`, body, true, job.repo_name);
398
509
  },
@@ -409,7 +520,7 @@ export function createExecutorHttpClient(config) {
409
520
  local_commit_count: failure.local_commit_count,
410
521
  last_commit_sha: failure.last_commit_sha,
411
522
  telemetry: failure.telemetry,
412
- ...completionContractFields(failure),
523
+ ...completionContractFields(job, failure),
413
524
  };
414
525
  if (failure.capacity_evidence === undefined) {
415
526
  return mutate(`/${job.id}/fail`, body, false, job.repo_name);