@bridge_gpt/mcp-server 0.2.38 → 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.
Files changed (85) hide show
  1. package/README.md +189 -14
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. 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 { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
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
- function normalizeVersion(stdout, stderr) {
83
- return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
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
- const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
187
- deny = await denyProbe({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
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
- // --- Claude user-config MCP shadowing (fatal by default, BAPI-727) ----
206
- // REFUSAL BY DEFAULT is the whole point. A `bridge-api` entry in the machine's
207
- // ~/.claude.json was observed to win over a linked worktree's provisioned
208
- // `.mcp.json`, so the worker silently talks to whatever endpoint that entry
209
- // names instead of the one the executor provisioned — a production-integrity
210
- // failure that produces no error, only wrong work. A warning-only check would
211
- // preserve exactly that silence; the explicit
212
- // BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING override is the deliberate operator
213
- // escape hatch for the cases where the collision is known and intended.
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
- // Inconclusive states (unreadable/malformed config) are warnings, never fatal:
216
- // "could not look" must not become "found a collision".
217
- try {
218
- const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
219
- const inspection = await inspect({
220
- claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
221
- platform: deps.platform,
222
- cwd: deps.cwd,
223
- // The executor's own checkout is the main repository. Preflight runs
224
- // before any job, so there is no prepared worktree to name here — the
225
- // per-job check in `job-runner.ts` covers that scope.
226
- mainRepositoryPath: deps.cwd,
227
- }, { readFile: deps.readFile });
228
- const policy = evaluateClaudeMcpShadowingPolicy(inspection, deps.env);
229
- warnings.push(...policy.warnings);
230
- if (!policy.ok)
231
- fatalFindings.push(...policy.refusals);
232
- }
233
- catch {
234
- warnings.push("Claude user-config MCP shadowing check did not complete; could not verify worker MCP integrity");
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 — EXACTLY the eight T2-accepted keys. `free_slots`
249
- * is supplied by the runner (dynamic). The internal-only `gh` auth capability is
250
- * intentionally NOT included (not accepted by the T2 request schema in this slice).
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
- proc.kill("SIGTERM");
51
- graceTimer = deps.setTimer(() => {
52
- // Still alive after the grace period — escalate.
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 { classification, exitCode, signal, stdoutExcerpt, stderrExcerpt };
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.
@@ -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") {