@bridge_gpt/mcp-server 0.2.69 → 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.
- package/README.md +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- 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
|
+
}
|
package/build/executor/deps.js
CHANGED
|
@@ -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
|
}
|
package/build/executor/env.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -217,6 +217,25 @@ function mapMutationResponse(status, bodyText, allowInvalidResult) {
|
|
|
217
217
|
return { outcome: "auth_fatal", authHttpStatus: status };
|
|
218
218
|
return { outcome: "fatal_http_error" };
|
|
219
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
|
+
}
|
|
220
239
|
/**
|
|
221
240
|
* Adapt the executor's injected `FetchLike` transport to the narrower
|
|
222
241
|
* `ConductorFetch` shape the shared read-only Bridge API GET helper expects
|
|
@@ -419,6 +438,59 @@ export function createExecutorHttpClient(config) {
|
|
|
419
438
|
return "failed";
|
|
420
439
|
}
|
|
421
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
|
+
},
|
|
422
494
|
complete(job, completion) {
|
|
423
495
|
const body = {
|
|
424
496
|
repo_name: job.repo_name,
|