@bridge_gpt/mcp-server 0.2.62 → 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.
- package/README.md +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- 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
|
+
};
|
|
@@ -83,7 +83,9 @@ export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl)
|
|
|
83
83
|
// sent to another. A restarted executor re-reads the binding here, so a unit
|
|
84
84
|
// whose --base-url drifted from the approved target refuses before its first
|
|
85
85
|
// authenticated request. An unbound (legacy) entry keeps today's behavior —
|
|
86
|
-
// the explicit-target requirement above still applies to it.
|
|
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.
|
|
87
89
|
if (result.credentials.source === "file") {
|
|
88
90
|
const bound = await readStoredBinding(`bapi:${trimmed}`, storeDeps);
|
|
89
91
|
if (bound !== null && bound !== url.baseUrl) {
|
package/build/executor/deps.js
CHANGED
|
@@ -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;
|
package/build/executor/env.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
killCurrent("deadman");
|
|
106
116
|
ownership.abandoned = true;
|
|
107
117
|
ownership.abandonReason = "deadman";
|
|
108
118
|
return { kind: "abandoned" };
|
|
@@ -22,17 +22,60 @@ function isCapacityEvidenceRefusal(status, bodyText) {
|
|
|
22
22
|
return true;
|
|
23
23
|
if (status !== 409)
|
|
24
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) {
|
|
25
39
|
try {
|
|
26
40
|
const parsed = JSON.parse(bodyText);
|
|
27
41
|
const detail = parsed && typeof parsed === "object" ? parsed.detail : undefined;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
if (!detail || typeof detail !== "object")
|
|
43
|
+
return null;
|
|
44
|
+
const code = detail.error_code;
|
|
45
|
+
return typeof code === "string" ? code : null;
|
|
31
46
|
}
|
|
32
47
|
catch {
|
|
33
|
-
return
|
|
48
|
+
return null;
|
|
34
49
|
}
|
|
35
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
|
+
}
|
|
36
79
|
/** Bound a server-provided diagnostic snippet included in error strings. */
|
|
37
80
|
function boundedDetail(text) {
|
|
38
81
|
const trimmed = (text ?? "").trim();
|
|
@@ -142,6 +185,22 @@ function toConductorGetFetch(fetchLike) {
|
|
|
142
185
|
};
|
|
143
186
|
};
|
|
144
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
|
+
}
|
|
145
204
|
export function createExecutorHttpClient(config) {
|
|
146
205
|
const base = config.baseUrl.replace(/\/+$/, "") + "/executor/jobs";
|
|
147
206
|
function keyForRepo(repoName) {
|
|
@@ -206,6 +265,12 @@ export function createExecutorHttpClient(config) {
|
|
|
206
265
|
if (!job || typeof job !== "object" || typeof job.claim_token !== "string") {
|
|
207
266
|
return { kind: "fatal", error: "claim returned a malformed job payload" };
|
|
208
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
|
+
}
|
|
209
274
|
return { kind: "claimed", job, reconcilerLiveness };
|
|
210
275
|
}
|
|
211
276
|
catch {
|
|
@@ -242,6 +307,11 @@ export function createExecutorHttpClient(config) {
|
|
|
242
307
|
local_commit_count: payload.local_commit_count,
|
|
243
308
|
last_commit_sha: payload.last_commit_sha,
|
|
244
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
|
+
: {}),
|
|
245
315
|
};
|
|
246
316
|
const res = await post(`/${job.id}/heartbeat`, body, job.repo_name);
|
|
247
317
|
status = res.status;
|
|
@@ -290,6 +360,7 @@ export function createExecutorHttpClient(config) {
|
|
|
290
360
|
local_commit_count: completion.local_commit_count,
|
|
291
361
|
last_commit_sha: completion.last_commit_sha,
|
|
292
362
|
telemetry: completion.telemetry,
|
|
363
|
+
...completionContractFields(completion),
|
|
293
364
|
};
|
|
294
365
|
return mutate(`/${job.id}/complete`, body, true, job.repo_name);
|
|
295
366
|
},
|
|
@@ -303,6 +374,7 @@ export function createExecutorHttpClient(config) {
|
|
|
303
374
|
local_commit_count: failure.local_commit_count,
|
|
304
375
|
last_commit_sha: failure.last_commit_sha,
|
|
305
376
|
telemetry: failure.telemetry,
|
|
377
|
+
...completionContractFields(failure),
|
|
306
378
|
};
|
|
307
379
|
if (failure.capacity_evidence === undefined) {
|
|
308
380
|
return mutate(`/${job.id}/fail`, body, false, job.repo_name);
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* never pushed, so there is nothing to recreate from (TDD §7).
|
|
14
14
|
* - `BranchMismatch` — a `resume` worktree is on the wrong branch; working there
|
|
15
15
|
* would corrupt an unrelated branch, so abort rather than proceed (TDD §7).
|
|
16
|
+
* - `ContinuationWorktreeMissing` / `ContinuationLineageMismatch` — a parked-child
|
|
17
|
+
* continuation (BAPI-1300) could not prove its retained work: the worktree is
|
|
18
|
+
* gone, or its HEAD does not descend from the authorized head. Refused before
|
|
19
|
+
* any local mutation; never recreated, reset, or cleaned.
|
|
16
20
|
* - `WorkerFinalizationMissingRemoteBranchAndPr` — an implementation-style job
|
|
17
21
|
* (BAPI-551) exited cleanly but produced neither a PR URL nor a pushed origin
|
|
18
22
|
* branch, stranding the implement gate; see `worker-finalization.ts`.
|
|
@@ -112,6 +116,18 @@ import { normalizeMcpServerNames } from "./observation.js";
|
|
|
112
116
|
export const MissingVerdictArtifact = "MissingVerdictArtifact";
|
|
113
117
|
export const WorktreeLostBeforePush = "WorktreeLostBeforePush";
|
|
114
118
|
export const BranchMismatch = "BranchMismatch";
|
|
119
|
+
/**
|
|
120
|
+
* BAPI-1300: a parked-child CONTINUATION found no retained worktree for its
|
|
121
|
+
* branch. A continuation never recreates one from origin — that would silently
|
|
122
|
+
* drop unpushed work the continuation exists to carry — so it refuses instead.
|
|
123
|
+
*/
|
|
124
|
+
export const ContinuationWorktreeMissing = "ContinuationWorktreeMissing";
|
|
125
|
+
/**
|
|
126
|
+
* BAPI-1300: the retained worktree's HEAD does not descend from the head the
|
|
127
|
+
* continuation was authorized against. Checked BEFORE the WIP checkpoint, so an
|
|
128
|
+
* unrelated history is never committed onto and never adopted as the new head.
|
|
129
|
+
*/
|
|
130
|
+
export const ContinuationLineageMismatch = "ContinuationLineageMismatch";
|
|
115
131
|
export const WorkerFinalizationMissingRemoteBranchAndPr = "WorkerFinalizationMissingRemoteBranchAndPr";
|
|
116
132
|
export const WorkerFinalizationPrBaseMismatch = "WorkerFinalizationPrBaseMismatch";
|
|
117
133
|
export const WorkerFinalizationPrNotAttached = "WorkerFinalizationPrNotAttached";
|
|
@@ -139,6 +155,38 @@ export const StaleArtifactCleanupFailed = "ContractError.StaleArtifactCleanupFai
|
|
|
139
155
|
* than proceeding on an unverified premise.
|
|
140
156
|
*/
|
|
141
157
|
export const WorkerEnvFilePresent = "ContractError.WorkerEnvFilePresent";
|
|
158
|
+
/**
|
|
159
|
+
* BAPI-1296: a `ci_fix` job could not be run as a FOCUSED repair. Raised before
|
|
160
|
+
* any worktree effect when the payload's focused contract is missing, malformed,
|
|
161
|
+
* or carries an explicit prompt (a legacy full-recipe prompt included), and under
|
|
162
|
+
* the worktree lock when the prepared checkout's repository, branch, or HEAD does
|
|
163
|
+
* not match the dispatched CI observation. The message carries a fixed
|
|
164
|
+
* `reason=<code>` token and never ticket, check, or log text. The job is refused
|
|
165
|
+
* rather than downgraded to `/implement-ticket`: re-running the whole recipe to
|
|
166
|
+
* fix one check is the cost this contract removes.
|
|
167
|
+
*/
|
|
168
|
+
export const CiFixContext = "ContractError.CiFixContext";
|
|
169
|
+
/**
|
|
170
|
+
* BAPI-1311: a `rebase` job carrying a `payload.focused_rebase` context could not
|
|
171
|
+
* be run as a FOCUSED merge-stage repair — an unsupported context version, a
|
|
172
|
+
* malformed or missing field, a blank server prompt, an identity that does not
|
|
173
|
+
* match the claimed job, or a claim that lacks the focused capability. Raised
|
|
174
|
+
* before any worktree effect. The message carries a fixed `reason=<code>` token
|
|
175
|
+
* and never prompt, ticket, or payload text. The job is refused rather than
|
|
176
|
+
* reinterpreted as a legacy rebase or downgraded to `/implement-ticket`.
|
|
177
|
+
*/
|
|
178
|
+
export const FocusedRebaseContext = "ContractError.FocusedRebaseContext";
|
|
179
|
+
/**
|
|
180
|
+
* BAPI-1311: a focused rebase pushed its repair but the post-push wait PROVED it
|
|
181
|
+
* cannot succeed — a required check concluded red at the pushed head, the pull
|
|
182
|
+
* request closed or reads unmergeable, or the bound pull request / head / head
|
|
183
|
+
* branch / base changed. The SAME kind the server records when its completion
|
|
184
|
+
* verifier rejects a rebase (`REBASE_CONTRACT_ERROR_KIND` in
|
|
185
|
+
* `api/library/epic_implementer/rebase_completion_contract.py`), so an executor-
|
|
186
|
+
* observed and a server-observed contract miss read as one state. Unavailable
|
|
187
|
+
* evidence at the deadline is NOT this kind; it is the ordinary `Timeout`.
|
|
188
|
+
*/
|
|
189
|
+
export const RebaseNotVerified = "ContractError.RebaseNotVerified";
|
|
142
190
|
/**
|
|
143
191
|
* BAPI-1152 — `reconcile_overlap` failure kinds. Each names ONE way the
|
|
144
192
|
* wrapper's merge-into-child could not reach a verified push, so an operator
|