@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.
Files changed (91) hide show
  1. package/README.md +193 -16
  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/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Test-only virtual clock + fake executor deps (BAPI-534).
3
+ *
4
+ * NOT a `*.test.ts` (so it is not auto-run) and imports nothing from the v1
5
+ * conductor graph — it only builds injectable fakes for the executor unit tests.
6
+ * The virtual clock fires timers in TIME order so concurrent `sleep`s and the
7
+ * process-timeout/no-op-duration timers interleave deterministically.
8
+ */
9
+ import { resolveAgentSpec } from "../agent-registry.js";
10
+ import { createClaudeExecutorAdapter } from "../agent-launchers/claude-executor-adapter.js";
11
+ import { validateExecutorAdapterCapabilities } from "../agent-launchers/executor-adapter.js";
1
12
  export class VirtualClock {
2
13
  t = 0;
3
14
  timers = [];
@@ -33,10 +44,38 @@ async function flushMicrotasks() {
33
44
  // worker spawn, adding several microtask turns. Drain generously so a
34
45
  // `tickUntil` flush still reaches the spawn/exit in one pass (draining an
35
46
  // already-empty queue is a harmless no-op).
36
- for (let i = 0; i < 64; i++)
47
+ //
48
+ // BAPI-791: there is no pre-spawn credential resolution any more — worker
49
+ // authentication is no longer checked before spawning at all — but the
50
+ // budget is kept generously wide rather than narrowed back down. The failure
51
+ // a wide budget prevents is SILENT and misleading — a `tickUntil(() =>
52
+ // spawned)` that returns before the spawn lets a test register its own timer
53
+ // FIRST, so the process appears to exit before a timeout that was actually
54
+ // armed correctly, and a timeout test passes/fails for the wrong reason.
55
+ for (let i = 0; i < 512; i++)
37
56
  await Promise.resolve();
38
57
  }
39
58
  /** A controllable fake owned process for `implement` spawn tests. */
59
+ /**
60
+ * The stream-json `system`/`init` line a healthy default worker emits (BAPI-790).
61
+ *
62
+ * Its server set matches the default `.mcp.json` fixture served by
63
+ * `makeFakeExecutorDeps`, so the per-spawn MCP surface assertion sees a MATCH and
64
+ * stays silent. Without it every unrelated spawn test would trip the advisory
65
+ * "no init event was observed" warning and start asserting against a log line it
66
+ * was never written to care about.
67
+ *
68
+ * A test that wants the absent/mismatch paths supplies its own stdout.
69
+ */
70
+ export const DEFAULT_WORKER_INIT_EVENT_LINE = `${JSON.stringify({
71
+ type: "system",
72
+ subtype: "init",
73
+ mcp_servers: [{ name: "bridge-api", status: "connected" }],
74
+ })}\n`;
75
+ /** A single-chunk stdout stream carrying {@link DEFAULT_WORKER_INIT_EVENT_LINE}. */
76
+ async function* defaultWorkerStdout() {
77
+ yield DEFAULT_WORKER_INIT_EVENT_LINE;
78
+ }
40
79
  export function makeControllableProcess(stdout) {
41
80
  let resolveWait;
42
81
  const waitP = new Promise((r) => {
@@ -47,7 +86,7 @@ export function makeControllableProcess(stdout) {
47
86
  return {
48
87
  proc: {
49
88
  pid: 4242,
50
- stdout: stdout ?? null,
89
+ stdout: stdout ?? defaultWorkerStdout(),
51
90
  stderr: null,
52
91
  wait: () => waitP,
53
92
  kill: (s) => {
@@ -67,17 +106,35 @@ export function makeControllableProcess(stdout) {
67
106
  }
68
107
  /** Build a full fake `ExecutorDeps`, driven by a `VirtualClock`. */
69
108
  export function makeFakeExecutorDeps(clock, overrides = {}) {
109
+ // BAPI-793: in-memory backing for the exclusive-create boundary the worktree
110
+ // lock uses. Declared here (not inside `base`) so the create, the read-back,
111
+ // and the removal all address ONE store — a lock that could be created but
112
+ // never read back would silently never release, and a second job in the same
113
+ // test would then see a phantom holder.
114
+ const exclusiveFiles = new Map();
70
115
  const base = {
71
116
  runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
72
117
  spawnProcess: () => {
73
118
  throw new Error("spawnProcess not configured for this test");
74
119
  },
75
120
  readFile: async (filePath) => {
76
- // BAPI-731: the prepared worktree's own `.mcp.json` must carry the required
77
- // `bridge-api` registration or the runner refuses to spawn (an isolated
78
- // worker has no operator-scoped registration to fall back to). Serve a
121
+ // A file this deps object created exclusively (the BAPI-793 worktree lock)
122
+ // reads back verbatim, so ownership-checked release works against the fake
123
+ // filesystem exactly as it does against a real one.
124
+ const exclusive = exclusiveFiles.get(filePath);
125
+ if (exclusive !== undefined)
126
+ return exclusive;
127
+ // The prepared worktree's own `.mcp.json` must carry the required
128
+ // `bridge-api` registration or the runner refuses to spawn. Serve a
79
129
  // minimal valid one so pre-existing spawn tests still reach the spawn they
80
130
  // were written to assert on.
131
+ //
132
+ // BAPI-790 made this fixture carry MORE weight, deliberately: the same
133
+ // file is now both the pre-spawn requirement AND the source of the
134
+ // `--mcp-config` path and the expected server-name set the init-event
135
+ // assertion checks. A test that wants to model a MISSING or MALFORMED
136
+ // registration must therefore override this read explicitly — it will not
137
+ // get that state by accident, which is the point.
81
138
  if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
82
139
  return JSON.stringify({ mcpServers: { "bridge-api": { command: "node", args: [] } } });
83
140
  }
@@ -87,7 +144,30 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
87
144
  },
88
145
  writeFile: async () => { },
89
146
  mkdir: async () => undefined,
90
- stat: async () => ({ mode: 0o644 }),
147
+ // BAPI-793 worktree lock, backed by a per-deps in-memory namespace. Real
148
+ // exclusivity semantics (a second create for the same path rejects with
149
+ // EEXIST) so a fixture that genuinely contends behaves like the filesystem,
150
+ // but scoped to this deps object so the many existing single-job spawn tests
151
+ // simply acquire, run, and release without touching a real directory.
152
+ writeFileExclusive: async (filePath, data) => {
153
+ if (exclusiveFiles.has(filePath)) {
154
+ throw Object.assign(new Error("EEXIST"), { code: "EEXIST" });
155
+ }
156
+ exclusiveFiles.set(filePath, data);
157
+ },
158
+ removeFile: async (filePath) => {
159
+ exclusiveFiles.delete(filePath);
160
+ },
161
+ stat: async (filePath) => {
162
+ // A blanket "every path exists" stat would make an absent credential
163
+ // store look unreadable instead of simply missing, so report it as
164
+ // absent by default. Fixtures that want a specific store state inject
165
+ // this seam explicitly.
166
+ if (typeof filePath === "string" && filePath.endsWith("credentials.json")) {
167
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
168
+ }
169
+ return { mode: 0o644 };
170
+ },
91
171
  statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
92
172
  // BAPI-731 worker config isolation boundaries. In-memory fakes: no test ever
93
173
  // creates, permissions, or removes a real directory. Isolation is
@@ -104,14 +184,12 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
104
184
  now: clock.now,
105
185
  setTimer: clock.setTimer,
106
186
  clearTimer: clock.clearTimer,
107
- // `ANTHROPIC_API_KEY` is the isolation strategy's required credential source
108
- // (see `worker-config-isolation.ts`); without it capability evaluation
109
- // reports `unsupported-auth-layout` and no spawn test could run.
110
- // `BAPI_API_KEY` is the seed credential for the isolated Bridge store
111
- // (BAPI-740/F2); without it the runner refuses pre-spawn with
112
- // `ContractError.BridgeCredentialUnavailable` and no spawn test could run.
187
+ // BAPI-791: there is no pre-spawn Claude authentication check any more —
188
+ // a worker authenticates (or fails to) at the real spawn, classified
189
+ // after the fact from its own captured output — so no Anthropic
190
+ // credential belongs in this default environment at all. `BAPI_API_KEY`
191
+ // remains a Bridge-only test credential unrelated to worker auth.
113
192
  env: {
114
- ANTHROPIC_API_KEY: "test-key-not-a-real-credential",
115
193
  BAPI_API_KEY: "test-bapi-key-not-a-real-credential",
116
194
  },
117
195
  cwd: "/repo",
@@ -127,19 +205,19 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
127
205
  },
128
206
  };
129
207
  const merged = { ...base, ...overrides };
130
- // BAPI-731: two fail-CLOSED spawn gates depend on ambient fixture state that
131
- // predates them, so an override supplied for an unrelated reason would
132
- // silently turn a spawn test into a refusal test. Both are re-applied here as
133
- // FALLBACKS — an override that deliberately exercises a refusal still wins,
134
- // because it either supplies its own key/registration or explicitly omits one.
135
- // 1. Isolation needs an Anthropic key, and the Bridge-store seeding refusal
136
- // (BAPI-740/F2) needs a resolvable `BAPI_API_KEY`. A test overriding `env`
137
- // for some other purpose keeps both unless it sets the key itself — an
138
- // override that deliberately exercises a refusal supplies its own value
139
- // (empty string included: presence wins over the fallback).
208
+ // An override supplied for an unrelated reason must not silently turn a
209
+ // spawn test into a refusal test, so ambient fixture state a fail-CLOSED
210
+ // gate depends on is re-applied here as a FALLBACK an override that
211
+ // deliberately exercises a refusal still wins, because it either supplies
212
+ // its own value or explicitly omits one (empty string included: presence
213
+ // wins over the fallback).
214
+ // 1. `BAPI_API_KEY` is a Bridge test credential unrelated to worker
215
+ // authentication (BAPI-791 removed the pre-spawn Anthropic-credential
216
+ // check entirely). A test overriding `env` for some other purpose keeps
217
+ // it unless it sets the key itself.
140
218
  if (overrides.env) {
141
219
  const fallbacks = {};
142
- for (const key of ["ANTHROPIC_API_KEY", "BAPI_API_KEY"]) {
220
+ for (const key of ["BAPI_API_KEY"]) {
143
221
  if (!(key in overrides.env))
144
222
  fallbacks[key] = base.env[key];
145
223
  }
@@ -148,9 +226,18 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
148
226
  // 2. The required-registration check reads the worktree's `.mcp.json`. A test
149
227
  // overriding `readFile` (usually to script command provisioning) still gets
150
228
  // the default registration for that one path when its own read rejects.
229
+ //
230
+ // BAPI-793: the same wrapper also serves back files this deps object created
231
+ // exclusively (the worktree lock), BEFORE consulting the override. A test
232
+ // that scripts `readFile` for command provisioning has no opinion about the
233
+ // lock file, and without this its override would hide the lock from its own
234
+ // ownership-checked release.
151
235
  if (overrides.readFile) {
152
236
  const override = overrides.readFile;
153
237
  merged.readFile = async (filePath) => {
238
+ const exclusive = exclusiveFiles.get(filePath);
239
+ if (exclusive !== undefined)
240
+ return exclusive;
154
241
  try {
155
242
  return await override(filePath);
156
243
  }
@@ -186,3 +273,80 @@ export function makeTestOptions(overrides = {}) {
186
273
  ...overrides,
187
274
  };
188
275
  }
276
+ /**
277
+ * BAPI-781: hermetic executor-adapter resolution for tests.
278
+ *
279
+ * Every spawn test now needs a resolved adapter, and building one inline in each
280
+ * file would spread the same eight lines across four suites. These helpers live
281
+ * here for the same reason the rest of this module does: they are injectable
282
+ * fakes, not production wiring, and they keep the real `agent-registry` →
283
+ * `createClaudeExecutorAdapter` path under test rather than substituting a
284
+ * hand-written stand-in for Claude.
285
+ */
286
+ export function claudeAdapterSeam(claudeDeps = {}) {
287
+ const spec = resolveAgentSpec("claude");
288
+ return (agentId) => {
289
+ const adapter = createClaudeExecutorAdapter(spec, {
290
+ // Hermetic defaults: an in-memory read boundary and no-op provisioning, so
291
+ // a test that does not care about deny/advisory behavior still resolves a
292
+ // COMPLETE adapter rather than tripping the fail-closed capability gate.
293
+ readFile: async () => "{}",
294
+ denyProvisioningDeps: {
295
+ readFile: async () => "{}",
296
+ writeFile: async () => { },
297
+ mkdir: async () => { },
298
+ homedir: () => "/home/test",
299
+ },
300
+ ...claudeDeps,
301
+ });
302
+ const validation = validateExecutorAdapterCapabilities(adapter);
303
+ if (!validation.ok) {
304
+ return {
305
+ supported: false,
306
+ kind: "incomplete-capabilities",
307
+ message: `test Claude adapter incomplete for '${agentId}'`,
308
+ refusals: validation.refusals,
309
+ };
310
+ }
311
+ return { supported: true, adapter, spec, validation };
312
+ };
313
+ }
314
+ /**
315
+ * A resolver that refuses, for the fail-closed cases. Deliberately returns a
316
+ * REFUSAL rather than throwing: the runner is required to convert a refusal into
317
+ * a named contract failure, and a thrown resolver would test the generic
318
+ * exception path instead of the one that matters.
319
+ */
320
+ export function refusingAdapterSeam(kind, message = "no usable executor adapter for this agent") {
321
+ return () => ({ supported: false, kind, message });
322
+ }
323
+ /**
324
+ * Build a synthetic adapter from a complete Claude adapter with individual
325
+ * capabilities overridden or removed. Used to prove the runner refuses a shape
326
+ * inconsistent with the declaration, without inventing a whole second agent.
327
+ */
328
+ export function syntheticAdapterSeam(mutate) {
329
+ const spec = resolveAgentSpec("claude");
330
+ return (agentId) => {
331
+ const base = createClaudeExecutorAdapter(spec, {
332
+ readFile: async () => "{}",
333
+ denyProvisioningDeps: {
334
+ readFile: async () => "{}",
335
+ writeFile: async () => { },
336
+ mkdir: async () => { },
337
+ homedir: () => "/home/test",
338
+ },
339
+ });
340
+ const adapter = mutate(base);
341
+ const validation = validateExecutorAdapterCapabilities(adapter);
342
+ if (!validation.ok) {
343
+ return {
344
+ supported: false,
345
+ kind: "incomplete-capabilities",
346
+ message: `synthetic adapter incomplete for '${agentId}'`,
347
+ refusals: validation.refusals,
348
+ };
349
+ }
350
+ return { supported: true, adapter, spec, validation };
351
+ };
352
+ }
@@ -1,38 +1,24 @@
1
1
  /**
2
- * Headless Claude worker command construction (BAPI-534, TDD §7).
2
+ * Executor worker JOB POLICY: prompt resolution and permission posture
3
+ * (BAPI-534, TDD §7; BAPI-790; narrowed in BAPI-781).
3
4
  *
4
- * Resolves the exact argv, model alias, and prompt for a spawned worker. Model
5
- * aliases resolve through the agent registry (never a raw untrusted string); no
6
- * MCP config is ever added. When no safe alias is available, `--model` is omitted.
5
+ * WHAT THIS MODULE IS NO LONGER. Until BAPI-781 it also built Claude's argv,
6
+ * resolved Claude's model alias, and named Claude's executable. All three moved
7
+ * into the Claude executor adapter
8
+ * (`agent-launchers/claude-executor-adapter.ts`), because each was a statement
9
+ * about one particular CLI's process shape. What remains here is genuinely
10
+ * agent-neutral: which prompt a job type gets, and which permission posture it
11
+ * runs under. Both are decisions about the JOB, and they read identically
12
+ * whether the worker is Claude, Codex, or cursor-agent.
13
+ *
14
+ * The permission posture stays here for a specific reason worth preserving: it
15
+ * is expressed in the SERVER's snake_case policy vocabulary, not in any CLI's
16
+ * flag spelling. The adapter translates it. Keeping the policy vocabulary
17
+ * distinct from the argv vocabulary is what stops a future flag rename in one
18
+ * agent's CLI from becoming an operator-visible policy break across all of them.
7
19
  */
8
- import { resolveAgentSpec, resolveModelAlias, isModelTier, isValidModelAlias, } from "../agent-registry.js";
9
20
  import { ExecutorNamedError } from "./job-errors.js";
10
21
  import { isRecoveryJobType } from "./job-types.js";
11
- /**
12
- * Resolve the Claude model alias from a job payload. Accepts a direct
13
- * `model_alias` only when it passes registry alias validation AND the static
14
- * Claude allowlist; otherwise resolves from a valid `model_tier` through the
15
- * registry. Returns `null` when no safe alias is available (so `--model` is
16
- * omitted) — an unsafe/invalid string is never returned.
17
- */
18
- export function resolveExecutorModelAlias(payload) {
19
- const agent = resolveAgentSpec("claude");
20
- if (!agent)
21
- return null;
22
- const p = payload && typeof payload === "object" ? payload : {};
23
- const directRaw = p.model_alias;
24
- if (typeof directRaw === "string" && directRaw.trim().length > 0) {
25
- const direct = directRaw.trim();
26
- const allowed = !agent.staticModelAliasAllowlist || agent.staticModelAliasAllowlist.includes(direct);
27
- if (isValidModelAlias(direct) && allowed) {
28
- return direct;
29
- }
30
- // An invalid/disallowed direct alias falls through to the tier path — never
31
- // returned as-is.
32
- }
33
- const tier = isModelTier(p.model_tier) ? p.model_tier : null;
34
- return resolveModelAlias(agent, tier, null);
35
- }
36
22
  /**
37
23
  * Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
38
24
  * Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
@@ -137,31 +123,9 @@ export function resolveWorkerPermissionPosture(payload) {
137
123
  throw new ExecutorNamedError("ContractError.PermissionPosture", "job payload worker_permission_posture is present but is not a supported posture " +
138
124
  "(expected 'skip_permissions' or 'accept_edits').");
139
125
  }
140
- /**
141
- * Build the exact headless Claude argv. `--model <alias>` is included only when
142
- * an alias is provided. No MCP config arguments are ever emitted.
143
- *
144
- * Exactly ONE permission form is emitted, selected by `posture`:
145
- * `--dangerously-skip-permissions` for `skip_permissions`, or the two adjacent
146
- * entries `--permission-mode acceptEdits` for `accept_edits`. They are mutually
147
- * exclusive by construction — emitting both would let the skip flag win silently
148
- * and make the policy key look effective while doing nothing.
149
- *
150
- * The posture changes how Claude PROMPTS, not what it may do: the deny layer is
151
- * provisioned by `job-runner.ts` before the spawn in both postures.
152
- */
153
- export function buildClaudeExecutorArgv(prompt, alias, posture = DEFAULT_WORKER_PERMISSION_POSTURE) {
154
- const argv = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
155
- if (alias) {
156
- argv.push("--model", alias);
157
- }
158
- if (posture === "accept_edits") {
159
- argv.push("--permission-mode", "acceptEdits");
160
- }
161
- else {
162
- argv.push("--dangerously-skip-permissions");
163
- }
164
- return argv;
165
- }
166
- /** The worker executable is always exactly `claude`. */
167
- export const CLAUDE_EXECUTABLE = "claude";
126
+ // Argv construction, model-alias resolution, and the worker executable name all
127
+ // live in the resolved executor adapter now (BAPI-781). `job-runner.ts` requests
128
+ // one complete `{ executable, argv, env }` spawn shape from the adapter rather
129
+ // than assembling a Claude-shaped command here. Re-adding a builder to this
130
+ // module would recreate the exact coupling the extraction removed: a generic
131
+ // job-policy module that only works for one CLI.
@@ -76,6 +76,88 @@ export async function* teeAsyncIterable(source, tee, stream = "stdout") {
76
76
  yield chunk;
77
77
  }
78
78
  }
79
+ /**
80
+ * Total bound on how much of a worker log the session-isolation invariant will
81
+ * scan. A worker log is unbounded (it is the raw stream), and the invariant is a
82
+ * cheap safety check rather than an analysis pass, so a multi-hundred-megabyte
83
+ * parse at job completion is not acceptable.
84
+ *
85
+ * The budget is split between the START and the END of the log (see
86
+ * {@link distinctWorkerLogSessionIds}); it is not a prefix.
87
+ */
88
+ export const MAX_SESSION_SCAN_CHARS = 2_000_000;
89
+ /**
90
+ * The DISTINCT `session_id` values present in a worker log (BAPI-793).
91
+ *
92
+ * The incident signature this exists to catch is two Claude sessions writing
93
+ * into one worktree's single `.conductor/worker.log`. Every stream-json record a
94
+ * worker emits carries its `session_id`, so one healthy job produces MANY
95
+ * records and exactly ONE distinct id — which is why this counts DISTINCT ids
96
+ * rather than occurrences. A count-based check would flag every normal job.
97
+ *
98
+ * Parsing is per line and failure-tolerant: the log interleaves stdout and
99
+ * stderr and may be truncated mid-line at either end, so an unparseable line is
100
+ * skipped rather than treated as evidence of anything.
101
+ *
102
+ * When the log exceeds {@link MAX_SESSION_SCAN_CHARS} the budget is split
103
+ * between its HEAD and its TAIL rather than spent entirely on a prefix. A
104
+ * head-only scan goes blind exactly where the risk is highest: the second worker
105
+ * is the one that arrives LATE, so its records land at the end of a log whose
106
+ * size is itself the product of two workers writing into it. Scanning both ends
107
+ * costs the same bounded work and catches the intruder wherever it entered.
108
+ *
109
+ * The middle is deliberately unscanned. Detecting a second session that both
110
+ * starts and ends inside the gap would require an unbounded parse, and this is a
111
+ * diagnostic, not the enforcement — the worktree lock is.
112
+ */
113
+ export function distinctWorkerLogSessionIds(logText) {
114
+ if (typeof logText !== "string" || logText.length === 0)
115
+ return [];
116
+ let scanned;
117
+ if (logText.length <= MAX_SESSION_SCAN_CHARS) {
118
+ scanned = logText;
119
+ }
120
+ else {
121
+ const window = Math.floor(MAX_SESSION_SCAN_CHARS / 2);
122
+ // Joined with a newline so the head's trailing partial line and the tail's
123
+ // leading partial line stay SEPARATE records. Concatenating them directly
124
+ // would splice two unrelated fragments into one syntactically-plausible
125
+ // line, which the parser could then read as a record that never existed.
126
+ scanned = `${logText.slice(0, window)}\n${logText.slice(logText.length - window)}`;
127
+ }
128
+ const seen = new Set();
129
+ for (const line of scanned.split(/\r?\n/)) {
130
+ if (line.length === 0 || !line.includes("session_id"))
131
+ continue;
132
+ let value;
133
+ try {
134
+ value = JSON.parse(line);
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ if (typeof value !== "object" || value === null || Array.isArray(value))
140
+ continue;
141
+ const sessionId = value.session_id;
142
+ if (typeof sessionId === "string" && sessionId.length > 0)
143
+ seen.add(sessionId);
144
+ }
145
+ return [...seen].sort();
146
+ }
147
+ /**
148
+ * The fixed diagnostic emitted when a completed job's worker log carries more
149
+ * than one distinct session (BAPI-793 acceptance criterion 3).
150
+ *
151
+ * Reports the COUNT, never the ids: a session id identifies a real agent
152
+ * conversation, and this string goes to the executor's stderr where it may be
153
+ * shipped to a log aggregator. The count alone is the actionable signal.
154
+ */
155
+ export function formatWorkerLogSessionInvariantWarning(sessionCount) {
156
+ return (`worker log session invariant violated: ${sessionCount} distinct Claude sessions wrote to this ` +
157
+ "job's .conductor/worker.log, so more than one worker was active in this worktree. The " +
158
+ "worktree lock should have refused the second one — treat this as evidence it was bypassed " +
159
+ "(a pre-existing stale lock, a worktree entered outside the executor, or a manual claude run).");
160
+ }
79
161
  /**
80
162
  * Safely finalize a tee. Tolerates a `null`/`undefined` tee and repeated calls;
81
163
  * NEVER kills or signals the worker process.