@bridge_gpt/mcp-server 0.2.37 → 0.2.39
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 +193 -16
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -1,23 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Executor preflight + capability manifest construction (BAPI-534, TDD §11, R8).
|
|
3
|
-
*
|
|
4
|
-
* Before claiming, verify local tooling (wt/git/claude), credentials, disk, and
|
|
5
|
-
* — the binding post-Wave-2 contract — deny-layer enforcement. REFUSE TO CLAIM
|
|
6
|
-
* on any fatal finding. The capability manifest (exactly the 8 T2-accepted keys)
|
|
7
|
-
* is built from these inspections. Local `gh` auth is captured in the internal
|
|
8
|
-
* report (for T5's fatal-before-merge preflight) but is NOT added to the T2
|
|
9
|
-
* claim manifest, which does not accept it in this slice.
|
|
10
|
-
*
|
|
11
|
-
* The deny-enforcement probe is injected (`seams.runDenyPreflight`) so unit tests
|
|
12
|
-
* never spawn a real headless `claude`; the default is the real
|
|
13
|
-
* `runDenyEnforcementPreflight` from the conductor deny-enforcement module (an
|
|
14
|
-
* allowed reuse surface — it does not pull the v1 event/ledger graph).
|
|
15
|
-
*/
|
|
16
|
-
import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
|
|
17
1
|
import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
|
|
18
|
-
import {
|
|
2
|
+
import { inspectExecutorAgentAdapter, inspectionForFailedResolution, } from "../agent-launchers/executor-adapter-inspection.js";
|
|
3
|
+
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
4
|
+
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
19
5
|
import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
20
|
-
const VERSION_DETAIL_MAX = 200;
|
|
21
6
|
/**
|
|
22
7
|
* Environment override for the deny probe's headless timeout (BAPI-722).
|
|
23
8
|
*
|
|
@@ -79,17 +64,108 @@ export function createDenyProbeCache() {
|
|
|
79
64
|
},
|
|
80
65
|
};
|
|
81
66
|
}
|
|
82
|
-
|
|
83
|
-
|
|
67
|
+
/** Fail-fast report shape for a preflight that refused before probing. */
|
|
68
|
+
function refusedPreflight(fatalFindings, warnings, adapter) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
fatalFindings,
|
|
72
|
+
warnings,
|
|
73
|
+
agentVersion: "",
|
|
74
|
+
wtVersion: "",
|
|
75
|
+
diskFreeGb: null,
|
|
76
|
+
ghAuthed: false,
|
|
77
|
+
denyEnforced: false,
|
|
78
|
+
adapter,
|
|
79
|
+
};
|
|
84
80
|
}
|
|
85
81
|
/**
|
|
86
82
|
* Collect the executor preflight report. Populates the manifest fields and
|
|
87
83
|
* accumulates fatal findings — the runner refuses to claim when any exist.
|
|
84
|
+
*
|
|
85
|
+
* ADAPTER RESOLUTION IS THE FIRST OPERATION (BAPI-781), and a failure returns
|
|
86
|
+
* immediately without running a single probe. That fail-fast is a real
|
|
87
|
+
* guarantee, not an optimization: every probe below spawns something or touches
|
|
88
|
+
* the filesystem, and an executor whose agent has no usable adapter cannot run
|
|
89
|
+
* work no matter what those probes report. Spending them would also make the
|
|
90
|
+
* report describe a host configuration nobody can use.
|
|
88
91
|
*/
|
|
89
92
|
export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
90
93
|
const fatalFindings = [];
|
|
91
94
|
const warnings = [];
|
|
95
|
+
// --- Executor adapter (fatal, FIRST, BAPI-781) ------------------------
|
|
96
|
+
const agentId = resolveExecutorAgentId(options.agentId);
|
|
97
|
+
const resolveAdapter = seams.resolveAdapter ??
|
|
98
|
+
((id) => resolveExecutorAgentAdapter(id, {
|
|
99
|
+
// The adapter's advisory inspection needs a read boundary. Supplying
|
|
100
|
+
// preflight's own injected `readFile` keeps the inspection hermetic in
|
|
101
|
+
// tests and read-only in production — the adapter never opens a file
|
|
102
|
+
// handle this module did not hand it.
|
|
103
|
+
claude: { readFile: deps.readFile },
|
|
104
|
+
}));
|
|
105
|
+
const resolution = resolveAdapter(agentId);
|
|
106
|
+
if (!resolution.supported) {
|
|
107
|
+
return refusedPreflight([resolution.message], warnings, inspectionForFailedResolution(agentId, resolution));
|
|
108
|
+
}
|
|
109
|
+
const adapter = resolution.adapter;
|
|
110
|
+
// --- Supported platform (fatal, BAPI-790 → adapter-declared, BAPI-781) --
|
|
111
|
+
// Still second, and still before every probe: this is a property of the host,
|
|
112
|
+
// so nothing below it needs to run to know the answer. What changed is WHO
|
|
113
|
+
// declares it. The gate used to be the executor's own hard-coded darwin/linux
|
|
114
|
+
// policy; it is now the resolved adapter's declared platform capability, so a
|
|
115
|
+
// future agent supported on a different set is not silently held to Claude's.
|
|
116
|
+
// It remains a PLATFORM check only — it does not require a credential, because
|
|
117
|
+
// a worker authenticates through the operator's interactive login.
|
|
118
|
+
const adapterInspection = await inspectExecutorAgentAdapter(adapter, deps, {
|
|
119
|
+
probeExecutable: true,
|
|
120
|
+
});
|
|
121
|
+
const platformSupport = adapterInspection.inspection.platform;
|
|
122
|
+
if (platformSupport && !platformSupport.supported) {
|
|
123
|
+
return refusedPreflight([platformSupport.message], warnings, adapterInspection.inspection);
|
|
124
|
+
}
|
|
125
|
+
// --- Agent executable (fatal) -----------------------------------------
|
|
126
|
+
// Probed BY THE ADAPTER's declaration — its executable name and its version
|
|
127
|
+
// argv — rather than by a literal `claude --version`. `agentVersion` keeps its
|
|
128
|
+
// existing normalized value so the HTTP claim manifest stays wire-compatible.
|
|
129
|
+
const executableReport = adapterInspection.inspection.executable;
|
|
130
|
+
const agentVersion = executableReport?.version ?? "";
|
|
131
|
+
if (!executableReport || !executableReport.probed || executableReport.failure !== null) {
|
|
132
|
+
const command = executableReport?.command ?? agentId;
|
|
133
|
+
fatalFindings.push(`${command} (headless agent) is not available on PATH`);
|
|
134
|
+
}
|
|
135
|
+
// --- Lifecycle declaration (fatal when absent, BAPI-781) --------------
|
|
136
|
+
// An adapter that declares `{ kind: "none" }` is fine — Claude genuinely has
|
|
137
|
+
// no cleanup left after BAPI-790. An adapter that declares nothing is refused,
|
|
138
|
+
// because "no lifecycle work" and "nobody specified the lifecycle" must not
|
|
139
|
+
// look the same to the runner that has to decide whether to sweep.
|
|
140
|
+
if (adapterInspection.inspection.lifecycleKind === null) {
|
|
141
|
+
fatalFindings.push("executor adapter declares no lifecycle behavior; refusing to claim (an explicit " +
|
|
142
|
+
"no-op lifecycle is required, absence is not treated as no-op)");
|
|
143
|
+
}
|
|
144
|
+
// --- Declared Bridge-managed auth carriers (BAPI-781) -----------------
|
|
145
|
+
// Validated ONLY when declared. Claude declares zero, so this loop does
|
|
146
|
+
// nothing for it — and that is the intended shape: there is no login gate, no
|
|
147
|
+
// credential-store read, and no pre-spawn authentication check for an agent
|
|
148
|
+
// whose workers inherit the operator's own login.
|
|
149
|
+
const authCapability = adapter.auth;
|
|
150
|
+
if (authCapability?.supported === true) {
|
|
151
|
+
for (const carrier of authCapability.value.managedAuthCarriers) {
|
|
152
|
+
try {
|
|
153
|
+
const validation = await carrier.validate(deps.env);
|
|
154
|
+
if (!validation.valid) {
|
|
155
|
+
fatalFindings.push(`declared auth carrier '${validation.carrierId}' failed validation: ${validation.detail}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
fatalFindings.push(`declared auth carrier '${carrier.carrierId}' could not be validated; refusing to claim`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
92
163
|
// --- Tooling probes ---------------------------------------------------
|
|
164
|
+
// Worktrunk is executor infrastructure, not an agent, so its version probe
|
|
165
|
+
// stays here rather than moving behind the adapter. It keeps its own bounded
|
|
166
|
+
// normalization for exactly the reason the agent probe had one: the value
|
|
167
|
+
// travels onto the wire in the claim manifest.
|
|
168
|
+
const normalizeVersion = (stdout, stderr) => (stdout || stderr || "").trim().slice(0, 200);
|
|
93
169
|
let wtVersion = "";
|
|
94
170
|
try {
|
|
95
171
|
const wt = await deps.runCommand(options.worktrunkBinary, ["--version"]);
|
|
@@ -111,19 +187,6 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
111
187
|
catch {
|
|
112
188
|
fatalFindings.push("git could not be probed");
|
|
113
189
|
}
|
|
114
|
-
let agentVersion = "";
|
|
115
|
-
try {
|
|
116
|
-
const claude = await deps.runCommand("claude", ["--version"]);
|
|
117
|
-
if (claude.exitCode === 0) {
|
|
118
|
-
agentVersion = normalizeVersion(claude.stdout, claude.stderr);
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
fatalFindings.push("claude (headless agent) is not available on PATH");
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
fatalFindings.push("claude could not be probed");
|
|
126
|
-
}
|
|
127
190
|
// --- Explicit base URL (fatal; never defaults to production, BAPI-676) ---
|
|
128
191
|
// Resolved before per-repo credentials so a missing URL reports ONCE rather
|
|
129
192
|
// than once per repo. The remaining probes still run and accumulate findings.
|
|
@@ -182,9 +245,36 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
182
245
|
if (cachedDeny !== undefined) {
|
|
183
246
|
deny = cachedDeny;
|
|
184
247
|
}
|
|
248
|
+
else if (seams.runDenyPreflight) {
|
|
249
|
+
// An explicitly injected probe still wins, so every existing test seam
|
|
250
|
+
// keeps working unchanged across the extraction.
|
|
251
|
+
deny = await seams.runDenyPreflight({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
|
|
252
|
+
seams.denyProbeCache?.set(deny);
|
|
253
|
+
}
|
|
185
254
|
else {
|
|
186
|
-
|
|
187
|
-
|
|
255
|
+
// Otherwise the ADAPTER's declared deny hook probes. The verdict is mapped
|
|
256
|
+
// back onto the established result shape so the cache, the fatal findings,
|
|
257
|
+
// and the claim manifest all keep their existing semantics — only who owns
|
|
258
|
+
// the probe changed.
|
|
259
|
+
//
|
|
260
|
+
// The capability is re-narrowed rather than asserted: validation already
|
|
261
|
+
// proved it supported, but a non-null assertion here would silently become
|
|
262
|
+
// a crash if the required-capability list ever changed. A refusal is the
|
|
263
|
+
// correct failure, and it costs one branch.
|
|
264
|
+
const denyCapability = adapter.denyEnforcement;
|
|
265
|
+
if (denyCapability?.supported !== true) {
|
|
266
|
+
throw new Error("adapter declares no deny-enforcement capability");
|
|
267
|
+
}
|
|
268
|
+
const verdict = await denyCapability.value.probeEnforcement({
|
|
269
|
+
timeoutMs: resolveDenyProbeTimeoutMs(deps.env),
|
|
270
|
+
});
|
|
271
|
+
deny = {
|
|
272
|
+
enforced: verdict.enforced,
|
|
273
|
+
layer: verdict.layer,
|
|
274
|
+
degraded: verdict.degraded,
|
|
275
|
+
warnings: verdict.warnings,
|
|
276
|
+
detail: verdict.detail,
|
|
277
|
+
};
|
|
188
278
|
// `set` itself refuses anything that is not a genuine success, so a failure
|
|
189
279
|
// cannot become sticky even if this call site were later moved.
|
|
190
280
|
seams.denyProbeCache?.set(deny);
|
|
@@ -202,37 +292,44 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
202
292
|
catch {
|
|
203
293
|
fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
|
|
204
294
|
}
|
|
205
|
-
// ---
|
|
206
|
-
//
|
|
207
|
-
// ~/.claude.json was observed to win over a linked worktree's
|
|
208
|
-
// `.mcp.json
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
295
|
+
// --- Advisory MCP-shadowing inspection (BAPI-727 → BAPI-790 → BAPI-781) --
|
|
296
|
+
// This was a refusal under BAPI-727, because a `bridge-api` entry in the
|
|
297
|
+
// machine's ~/.claude.json was observed to win over a linked worktree's
|
|
298
|
+
// provisioned `.mcp.json`. Under BAPI-790 it cannot: every worker is spawned
|
|
299
|
+
// with `--strict-mcp-config --mcp-config <worktree>/.mcp.json`, so an operator
|
|
300
|
+
// registration of the same name is not loaded at all (inventory Finding 6),
|
|
301
|
+
// and the per-spawn init-event assertion verifies that on every single spawn
|
|
302
|
+
// rather than trusting the flag. The collision is therefore worth REPORTING —
|
|
303
|
+
// it is still confusing, and an operator generally wants it gone — but it can
|
|
304
|
+
// no longer produce wrong work, so refusing to claim over it would block a
|
|
305
|
+
// host for a condition that has no effect.
|
|
214
306
|
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
307
|
+
// BAPI-781 routes it through the ADAPTER's advisory hook, so this module no
|
|
308
|
+
// longer imports Claude's user-config inspector. The capability is optional by
|
|
309
|
+
// design: an adapter without one produces no warnings, which is correct —
|
|
310
|
+
// making it required would turn an advisory nicety into a claim gate.
|
|
311
|
+
//
|
|
312
|
+
// Inconclusive states (unreadable/malformed config) remain warnings for the
|
|
313
|
+
// same reason they always were: "could not look" must not become "found a
|
|
314
|
+
// collision".
|
|
315
|
+
const advisory = adapter.advisoryMcpInspection;
|
|
316
|
+
if (advisory?.supported === true) {
|
|
317
|
+
try {
|
|
318
|
+
const result = await advisory.value.inspect({
|
|
319
|
+
platform: deps.platform,
|
|
320
|
+
homedir: deps.homedir(),
|
|
321
|
+
// The executor's own checkout is the main repository. Preflight runs
|
|
322
|
+
// before any job, so there is no prepared worktree to name here — the
|
|
323
|
+
// per-job check in `job-runner.ts` covers that scope.
|
|
324
|
+
cwd: deps.cwd,
|
|
325
|
+
});
|
|
326
|
+
warnings.push(...result.warnings);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
warnings.push("advisory worker-MCP inspection did not complete; could not verify worker MCP integrity");
|
|
330
|
+
}
|
|
235
331
|
}
|
|
332
|
+
warnings.push(...adapterInspection.inspection.warnings);
|
|
236
333
|
return {
|
|
237
334
|
ok: fatalFindings.length === 0,
|
|
238
335
|
fatalFindings,
|
|
@@ -242,12 +339,22 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
242
339
|
diskFreeGb,
|
|
243
340
|
ghAuthed,
|
|
244
341
|
denyEnforced,
|
|
342
|
+
adapter: adapterInspection.inspection,
|
|
245
343
|
};
|
|
246
344
|
}
|
|
247
345
|
/**
|
|
248
|
-
* Build the HTTP claim manifest —
|
|
249
|
-
*
|
|
250
|
-
*
|
|
346
|
+
* Build the HTTP claim manifest — the eight original T2-accepted keys, plus the
|
|
347
|
+
* optional ninth `epic_run_ids` key (BAPI-794) when the executor was configured
|
|
348
|
+
* with one or more `--epic-run-id` flags. `free_slots` is supplied by the runner
|
|
349
|
+
* (dynamic). The internal-only `gh` auth capability is intentionally NOT included
|
|
350
|
+
* (not accepted by the T2 request schema in this slice).
|
|
351
|
+
*
|
|
352
|
+
* `epic_run_ids` is added via a conditional spread, not a bare property
|
|
353
|
+
* assignment: `{ epic_run_ids: options.epicRunIds }` would still create the key
|
|
354
|
+
* (with value `undefined`) when unscoped, which `Object.keys()` — unlike
|
|
355
|
+
* `JSON.stringify` — does NOT drop. The eight-key compatibility contract is
|
|
356
|
+
* pinned on `Object.keys()`, so the key must be truly absent, not merely
|
|
357
|
+
* `undefined`-valued.
|
|
251
358
|
*/
|
|
252
359
|
export function buildClaimManifest(report, options, freeSlots) {
|
|
253
360
|
return {
|
|
@@ -259,5 +366,6 @@ export function buildClaimManifest(report, options, freeSlots) {
|
|
|
259
366
|
max_concurrent: options.maxConcurrent,
|
|
260
367
|
free_slots: freeSlots,
|
|
261
368
|
disk_free_gb: report.diskFreeGb ?? 0,
|
|
369
|
+
...(options.epicRunIds !== undefined ? { epic_run_ids: options.epicRunIds } : {}),
|
|
262
370
|
};
|
|
263
371
|
}
|
|
@@ -30,28 +30,53 @@ async function pump(iterable, onChunk) {
|
|
|
30
30
|
* On timeout: SIGTERM, then SIGKILL after the grace period if still alive;
|
|
31
31
|
* classification `timeout`. On normal exit: `clean_exit` (code 0) or `crashed`
|
|
32
32
|
* (non-zero). Retained stdout/stderr excerpts are bounded.
|
|
33
|
+
*
|
|
34
|
+
* BAPI-790: a stdout observer may also request termination mid-run (the MCP
|
|
35
|
+
* surface assertion). That path REUSES the timeout path's exact TERM → grace →
|
|
36
|
+
* KILL mechanics rather than introducing a second way to signal a process — one
|
|
37
|
+
* escalation implementation means one place where the grace period, the kill
|
|
38
|
+
* escalation, and the "already terminating" guard can be reasoned about.
|
|
39
|
+
* `terminationRequested` is reported alongside the classification for the same
|
|
40
|
+
* reason `serverStopRequested` is: the caller needs to distinguish a deliberate
|
|
41
|
+
* contract termination from a worker that merely died.
|
|
33
42
|
*/
|
|
34
43
|
export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options = {}) {
|
|
35
44
|
const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
|
|
36
45
|
const limit = options.excerptLimitBytes ?? DEFAULT_EXCERPT_BYTES;
|
|
37
46
|
let stdoutExcerpt = "";
|
|
38
47
|
let stderrExcerpt = "";
|
|
48
|
+
let terminationRequested = false;
|
|
49
|
+
let terminating = false;
|
|
50
|
+
let graceTimer;
|
|
51
|
+
/** TERM now, KILL after the grace period. Idempotent — never signals twice. */
|
|
52
|
+
const terminate = () => {
|
|
53
|
+
if (terminating)
|
|
54
|
+
return;
|
|
55
|
+
terminating = true;
|
|
56
|
+
proc.kill("SIGTERM");
|
|
57
|
+
graceTimer = deps.setTimer(() => {
|
|
58
|
+
proc.kill("SIGKILL");
|
|
59
|
+
}, termGraceMs);
|
|
60
|
+
};
|
|
39
61
|
const pumpStdout = pump(proc.stdout, (chunk) => {
|
|
62
|
+
// Advisory observation ALWAYS runs first and always runs: the tee and the
|
|
63
|
+
// telemetry must see every chunk regardless of what the assertion decides.
|
|
40
64
|
options.onStdout?.(chunk);
|
|
41
65
|
stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
|
|
66
|
+
if (options.onStdoutTerminationCheck?.(chunk) === true && !terminationRequested) {
|
|
67
|
+
terminationRequested = true;
|
|
68
|
+
terminate();
|
|
69
|
+
}
|
|
42
70
|
});
|
|
43
71
|
const pumpStderr = pump(proc.stderr, (chunk) => {
|
|
44
72
|
stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
|
|
45
73
|
});
|
|
46
74
|
let timedOut = false;
|
|
47
|
-
let graceTimer;
|
|
48
75
|
const timeoutTimer = deps.setTimer(() => {
|
|
49
76
|
timedOut = true;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
proc.kill("SIGKILL");
|
|
54
|
-
}, termGraceMs);
|
|
77
|
+
// Same escalation the assertion uses; the guard inside makes a timeout that
|
|
78
|
+
// races an already-requested termination a no-op rather than a double signal.
|
|
79
|
+
terminate();
|
|
55
80
|
}, timeoutSeconds * 1000);
|
|
56
81
|
const { exitCode, signal } = await proc.wait();
|
|
57
82
|
deps.clearTimer(timeoutTimer);
|
|
@@ -69,7 +94,14 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
69
94
|
else {
|
|
70
95
|
classification = "crashed";
|
|
71
96
|
}
|
|
72
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
classification,
|
|
99
|
+
exitCode,
|
|
100
|
+
signal,
|
|
101
|
+
stdoutExcerpt,
|
|
102
|
+
stderrExcerpt,
|
|
103
|
+
...(terminationRequested ? { terminationRequested: true } : {}),
|
|
104
|
+
};
|
|
73
105
|
}
|
|
74
106
|
/**
|
|
75
107
|
* Immediately SIGKILL an owned process for a stale-claim or dead-man abandonment.
|
package/build/executor/runner.js
CHANGED
|
@@ -28,6 +28,15 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
28
28
|
platform: deps.platform,
|
|
29
29
|
}));
|
|
30
30
|
const active = new Map();
|
|
31
|
+
// BAPI-794 — process-local cross-run claim observability. Retains only the
|
|
32
|
+
// last successfully claimed job's `epic_run_id` (never job payloads,
|
|
33
|
+
// credentials, or arguments) so an unintended repo-wide move between epic
|
|
34
|
+
// runs is VISIBLE without changing queue scheduling: this state is read-only
|
|
35
|
+
// diagnostics and never feeds back into claim eligibility, fairness, retries,
|
|
36
|
+
// or any cross-process coordination — the backend's claim predicate remains
|
|
37
|
+
// the sole eligibility authority. Declared here, outside both loops below, so
|
|
38
|
+
// it persists across poll cycles for the lifetime of this `runExecutor` call.
|
|
39
|
+
let lastClaimedEpicRunId = null;
|
|
31
40
|
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
32
41
|
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
33
42
|
// the loop would be discarded every cycle and re-probe exactly as before; a
|
|
@@ -92,6 +101,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
92
101
|
const manifest = buildClaimManifest(report, options, freeSlots);
|
|
93
102
|
const result = await httpClient.claim(manifest);
|
|
94
103
|
if (result.kind === "claimed") {
|
|
104
|
+
const currentEpicRunId = result.job.epic_run_id ?? null;
|
|
105
|
+
if (lastClaimedEpicRunId !== null &&
|
|
106
|
+
currentEpicRunId !== null &&
|
|
107
|
+
currentEpicRunId !== lastClaimedEpicRunId) {
|
|
108
|
+
deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
|
|
109
|
+
`(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
|
|
110
|
+
`current_epic_run_id=${currentEpicRunId} scoped=${options.epicRunIds !== undefined})`);
|
|
111
|
+
}
|
|
112
|
+
if (currentEpicRunId !== null)
|
|
113
|
+
lastClaimedEpicRunId = currentEpicRunId;
|
|
95
114
|
dispatch(result.job, report);
|
|
96
115
|
}
|
|
97
116
|
else if (result.kind === "none") {
|