@bridge_gpt/mcp-server 0.2.66 → 0.2.68
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 +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bounded-wait.js +67 -10
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +112 -26
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
package/build/plane/manifest.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* argv, and no credential field, and unknown keys are rejected outright.
|
|
18
18
|
*/
|
|
19
19
|
import path from "path";
|
|
20
|
-
import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
20
|
+
import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RESTART_FAILURE_REASONS, PLANE_RESTART_REQUEST_FILENAME, PLANE_RESTART_RESULT_FILENAME, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
21
21
|
/**
|
|
22
22
|
* Derive every runtime path beneath a validated repository root.
|
|
23
23
|
*
|
|
@@ -27,12 +27,62 @@ import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORT
|
|
|
27
27
|
*/
|
|
28
28
|
export function getPlanePaths(repoRoot) {
|
|
29
29
|
const planeDir = path.join(repoRoot, ".bridge", "plane");
|
|
30
|
+
const snapshotsDir = path.join(planeDir, "snapshots");
|
|
30
31
|
return {
|
|
31
32
|
planeDir,
|
|
32
33
|
manifestPath: path.join(planeDir, PLANE_MANIFEST_FILENAME),
|
|
33
34
|
logPathFor: (member) => path.join(planeDir, `${member}.log`),
|
|
35
|
+
snapshotsDir,
|
|
36
|
+
// The leaf is the ONLY variable part and it is shape-checked before it is
|
|
37
|
+
// joined: a value that is not a bare hexadecimal commit id throws rather than
|
|
38
|
+
// producing a path, so no caller-supplied string can steer a snapshot path
|
|
39
|
+
// out of `.bridge/plane/snapshots/`.
|
|
40
|
+
snapshotPathFor: (sha) => {
|
|
41
|
+
if (!isFullCommitSha(sha)) {
|
|
42
|
+
throw new Error("a snapshot path can only be derived from a full hexadecimal commit id");
|
|
43
|
+
}
|
|
44
|
+
return path.join(snapshotsDir, sha);
|
|
45
|
+
},
|
|
46
|
+
// Fixed file names. The restart request and result are correlated by the
|
|
47
|
+
// identifiers INSIDE them, never by a name derived from their content.
|
|
48
|
+
restartRequestPath: path.join(planeDir, PLANE_RESTART_REQUEST_FILENAME),
|
|
49
|
+
restartResultPath: path.join(planeDir, PLANE_RESTART_RESULT_FILENAME),
|
|
34
50
|
};
|
|
35
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A full Git object id: 40 hexadecimal characters (SHA-1) or 64 (SHA-256).
|
|
54
|
+
*
|
|
55
|
+
* Lowercase only. `git rev-parse` prints lowercase, so an uppercase spelling is
|
|
56
|
+
* not something this code produced, and accepting it would let two spellings of
|
|
57
|
+
* one commit name two different snapshot directories.
|
|
58
|
+
*/
|
|
59
|
+
export function isFullCommitSha(value) {
|
|
60
|
+
return typeof value === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A canonical absolute path: absolute, already normalized (no `.` or `..`
|
|
64
|
+
* segment, no doubled or trailing separator), and free of control characters.
|
|
65
|
+
*
|
|
66
|
+
* "Canonical" here is lexical — it says the string IS its own normal form — and
|
|
67
|
+
* it does not claim the path exists or is free of symlinks. Symlink resolution
|
|
68
|
+
* needs a filesystem and belongs to the snapshot module's ownership checks.
|
|
69
|
+
*/
|
|
70
|
+
export function isCanonicalAbsolutePath(value) {
|
|
71
|
+
if (typeof value !== "string" || value.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
// eslint-disable-next-line no-control-regex
|
|
74
|
+
if (/[\u0000-\u001f]/.test(value))
|
|
75
|
+
return false;
|
|
76
|
+
if (!path.isAbsolute(value))
|
|
77
|
+
return false;
|
|
78
|
+
return path.normalize(value) === value && (value === path.parse(value).root || !value.endsWith(path.sep));
|
|
79
|
+
}
|
|
80
|
+
export function planeManifestSnapshot(manifest) {
|
|
81
|
+
if (manifest.snapshotPath !== undefined && manifest.snapshotSha !== undefined) {
|
|
82
|
+
return { kind: "pinned", snapshot: { path: manifest.snapshotPath, sha: manifest.snapshotSha } };
|
|
83
|
+
}
|
|
84
|
+
return { kind: "legacy" };
|
|
85
|
+
}
|
|
36
86
|
/** Repository-relative log path recorded in the manifest for a member. */
|
|
37
87
|
export function relativeLogPathFor(member) {
|
|
38
88
|
return `${PLANE_RUNTIME_DIR}/${member}.log`;
|
|
@@ -42,7 +92,10 @@ const VALID_STATES = new Set([
|
|
|
42
92
|
"running",
|
|
43
93
|
"ready",
|
|
44
94
|
"exited",
|
|
95
|
+
"restarting",
|
|
45
96
|
]);
|
|
97
|
+
/** Every legal `restartFailure` value, for the strict member parse. */
|
|
98
|
+
const RESTART_FAILURE_REASONS = new Set(PLANE_RESTART_FAILURE_REASONS);
|
|
46
99
|
const MANIFEST_KEYS = new Set([
|
|
47
100
|
"schemaVersion",
|
|
48
101
|
"planeId",
|
|
@@ -58,6 +111,9 @@ const MANIFEST_KEYS = new Set([
|
|
|
58
111
|
// starts its lanes with. Both optional; both absent on a v1/v2 manifest.
|
|
59
112
|
"lifecycle",
|
|
60
113
|
"laneScope",
|
|
114
|
+
// BAPI-1362: the pinned code snapshot. Optional, and only ever both or neither.
|
|
115
|
+
"snapshotPath",
|
|
116
|
+
"snapshotSha",
|
|
61
117
|
]);
|
|
62
118
|
/** Every legal `lifecycle` value, as a set for the strict parse below. */
|
|
63
119
|
const LIFECYCLE_PHASES = new Set(PLANE_LIFECYCLE_PHASES);
|
|
@@ -72,6 +128,9 @@ const MEMBER_KEYS = new Set([
|
|
|
72
128
|
"exitCode",
|
|
73
129
|
"exitSignal",
|
|
74
130
|
"logPath",
|
|
131
|
+
// BAPI-1362: both optional, both absent on a pre-v4 manifest.
|
|
132
|
+
"cwd",
|
|
133
|
+
"restartFailure",
|
|
75
134
|
]);
|
|
76
135
|
/**
|
|
77
136
|
* `server`, `worker`, `observer`, or `executor-<positive integer>`. Nothing else.
|
|
@@ -160,6 +219,9 @@ export function parsePlaneManifest(value) {
|
|
|
160
219
|
const laneScope = parseManifestLaneScope(record.laneScope);
|
|
161
220
|
if (!laneScope.ok)
|
|
162
221
|
return { ok: false, error: laneScope.error };
|
|
222
|
+
const snapshot = parseManifestSnapshot(record.snapshotPath, record.snapshotSha, record.repoRoot);
|
|
223
|
+
if (!snapshot.ok)
|
|
224
|
+
return { ok: false, error: snapshot.error };
|
|
163
225
|
const members = [];
|
|
164
226
|
const unrecognizedMemberNames = [];
|
|
165
227
|
const seen = new Set();
|
|
@@ -215,6 +277,20 @@ export function parsePlaneManifest(value) {
|
|
|
215
277
|
if (member.logPath !== relativeLogPathFor(member.name)) {
|
|
216
278
|
return { ok: false, error: `manifest member '${member.name}' has an unexpected log path` };
|
|
217
279
|
}
|
|
280
|
+
// BAPI-1362. `cwd`, when present, must be its own canonical absolute form:
|
|
281
|
+
// it is later compared against a real checkout path to decide whether a
|
|
282
|
+
// base-worktree fast-forward is safe, and a relative or `..`-bearing value
|
|
283
|
+
// could be made to match or to miss a path it does not name.
|
|
284
|
+
if (member.cwd !== undefined && !isCanonicalAbsolutePath(member.cwd)) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: `manifest member '${member.name}' has a working directory that is not a canonical absolute path`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (member.restartFailure !== undefined &&
|
|
291
|
+
!RESTART_FAILURE_REASONS.has(member.restartFailure)) {
|
|
292
|
+
return { ok: false, error: `manifest member '${member.name}' has an invalid restart failure` };
|
|
293
|
+
}
|
|
218
294
|
members.push({
|
|
219
295
|
name: member.name,
|
|
220
296
|
pid: member.pid,
|
|
@@ -222,6 +298,10 @@ export function parsePlaneManifest(value) {
|
|
|
222
298
|
exitCode: member.exitCode,
|
|
223
299
|
exitSignal: member.exitSignal,
|
|
224
300
|
logPath: member.logPath,
|
|
301
|
+
...(member.cwd !== undefined ? { cwd: member.cwd } : {}),
|
|
302
|
+
...(member.restartFailure !== undefined
|
|
303
|
+
? { restartFailure: member.restartFailure }
|
|
304
|
+
: {}),
|
|
225
305
|
});
|
|
226
306
|
}
|
|
227
307
|
// Every listed member was unrecognized: there is nothing this build can act
|
|
@@ -255,9 +335,48 @@ export function parsePlaneManifest(value) {
|
|
|
255
335
|
? { lifecycle: record.lifecycle }
|
|
256
336
|
: {}),
|
|
257
337
|
...(laneScope.scope !== undefined ? { laneScope: laneScope.scope } : {}),
|
|
338
|
+
...(snapshot.snapshot !== undefined
|
|
339
|
+
? { snapshotPath: snapshot.snapshot.path, snapshotSha: snapshot.snapshot.sha }
|
|
340
|
+
: {}),
|
|
258
341
|
},
|
|
259
342
|
};
|
|
260
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Validate the optional snapshot identity pair (BAPI-1362).
|
|
346
|
+
*
|
|
347
|
+
* Three rules, each a hard rejection rather than a coerced default:
|
|
348
|
+
*
|
|
349
|
+
* 1. The two fields arrive TOGETHER. A half-pinned record would let a consumer
|
|
350
|
+
* read "pinned" off one field while the other said nothing.
|
|
351
|
+
* 2. The SHA is a full lowercase commit id.
|
|
352
|
+
* 3. The path is EXACTLY `<repoRoot>/.bridge/plane/snapshots/<sha>`. The manifest
|
|
353
|
+
* is untrusted input that later steers a restart at a directory, so it is
|
|
354
|
+
* never allowed to name an arbitrary path — the only legal spelling is the one
|
|
355
|
+
* this build derives from the repository root and the SHA.
|
|
356
|
+
*/
|
|
357
|
+
function parseManifestSnapshot(snapshotPath, snapshotSha, repoRoot) {
|
|
358
|
+
if (snapshotPath === undefined && snapshotSha === undefined)
|
|
359
|
+
return { ok: true };
|
|
360
|
+
if (snapshotPath === undefined) {
|
|
361
|
+
return { ok: false, error: "manifest snapshot SHA is present without its snapshot path" };
|
|
362
|
+
}
|
|
363
|
+
if (snapshotSha === undefined) {
|
|
364
|
+
return { ok: false, error: "manifest snapshot path is present without its snapshot SHA" };
|
|
365
|
+
}
|
|
366
|
+
if (!isFullCommitSha(snapshotSha)) {
|
|
367
|
+
return { ok: false, error: "manifest snapshot SHA is not a full commit id" };
|
|
368
|
+
}
|
|
369
|
+
if (typeof repoRoot !== "string" || !isCanonicalAbsolutePath(repoRoot)) {
|
|
370
|
+
return { ok: false, error: "manifest snapshot cannot be validated without a canonical repository root" };
|
|
371
|
+
}
|
|
372
|
+
if (snapshotPath !== getPlanePaths(repoRoot).snapshotPathFor(snapshotSha)) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: "manifest snapshot path is not the canonical snapshot location for its SHA",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return { ok: true, snapshot: { path: snapshotPath, sha: snapshotSha } };
|
|
379
|
+
}
|
|
261
380
|
/**
|
|
262
381
|
* Validate the optional `laneScope` record (BAPI-1102).
|
|
263
382
|
*
|
|
@@ -417,7 +536,22 @@ export async function writePlaneManifest(manifest, fs) {
|
|
|
417
536
|
// atomic replacement rather than a copy across a mount boundary.
|
|
418
537
|
const tempPath = `${manifestPath}.${manifest.planeId}.${process.pid}.${manifestWriteCounter}.tmp`;
|
|
419
538
|
await fs.writeFile(tempPath, formatPlaneManifest(manifest));
|
|
420
|
-
|
|
539
|
+
// Windows can briefly refuse replacement while another writer or reader
|
|
540
|
+
// holds the destination. Retry the atomic rename; never remove the published
|
|
541
|
+
// manifest or rewrite this writer's private snapshot.
|
|
542
|
+
for (let attempt = 0;; attempt += 1) {
|
|
543
|
+
try {
|
|
544
|
+
await fs.rename(tempPath, manifestPath);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
const code = error.code;
|
|
549
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(code ?? "") || attempt >= 4) {
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
421
555
|
}
|
|
422
556
|
/**
|
|
423
557
|
* Remove the manifest, but only when it still belongs to `planeId`.
|
|
@@ -65,19 +65,82 @@ export const PLANE_RECONCILER_READINESS_TIMEOUT_MS = 120_000;
|
|
|
65
65
|
* fail a lane that recovers on its second try.
|
|
66
66
|
*/
|
|
67
67
|
export const PLANE_EXECUTOR_READINESS_TIMEOUT_MS = 90_000;
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/** Path of a virtualenv executable inside the operator checkout. */
|
|
69
|
+
function operatorVenvExecutable(lookup, name) {
|
|
70
|
+
return lookup.platform === "win32"
|
|
71
|
+
? path.join(lookup.operatorRoot, ".venv", "Scripts", `${name}.exe`)
|
|
72
|
+
: path.join(lookup.operatorRoot, ".venv", "bin", name);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Executable used for the Python members.
|
|
76
|
+
*
|
|
77
|
+
* Precedence: the documented `BAPI_PLANE_PYTHON` override, then the operator
|
|
78
|
+
* checkout's absolute `.venv` interpreter when one is found, then the bare name.
|
|
79
|
+
* The last step is the historical behavior, kept for a machine that keeps its
|
|
80
|
+
* environment elsewhere: an ABSOLUTE path to a file that is not there would turn
|
|
81
|
+
* "uses whatever `python` is on PATH" into "every member fails to spawn".
|
|
82
|
+
*/
|
|
83
|
+
export function resolvePythonExecutable(env, venv) {
|
|
70
84
|
const configured = env.BAPI_PLANE_PYTHON;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
if (typeof configured === "string" && configured.trim().length > 0)
|
|
86
|
+
return configured.trim();
|
|
87
|
+
if (venv) {
|
|
88
|
+
const candidate = operatorVenvExecutable(venv, "python");
|
|
89
|
+
if (venv.fileExists(candidate))
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
return "python";
|
|
74
93
|
}
|
|
75
|
-
/** Executable used for the server member. */
|
|
76
|
-
export function resolveUvicornExecutable(env) {
|
|
94
|
+
/** Executable used for the server member. Same precedence as {@link resolvePythonExecutable}. */
|
|
95
|
+
export function resolveUvicornExecutable(env, venv) {
|
|
77
96
|
const configured = env.BAPI_PLANE_UVICORN;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
if (typeof configured === "string" && configured.trim().length > 0)
|
|
98
|
+
return configured.trim();
|
|
99
|
+
if (venv) {
|
|
100
|
+
const candidate = operatorVenvExecutable(venv, "uvicorn");
|
|
101
|
+
if (venv.fileExists(candidate))
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
return "uvicorn";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Drop import-path entries that point at the MOVING operator checkout from a
|
|
108
|
+
* snapshot member's `PYTHONPATH` (BAPI-1362).
|
|
109
|
+
*
|
|
110
|
+
* A Python member launched from a snapshot imports its application code from the
|
|
111
|
+
* snapshot because the interpreter puts the script directory (or, for uvicorn,
|
|
112
|
+
* the working directory) first on `sys.path`. An inherited `PYTHONPATH` entry
|
|
113
|
+
* that names the operator checkout would sit beside that and could still satisfy
|
|
114
|
+
* an import the snapshot does not — silently loading code that changes under the
|
|
115
|
+
* plane. Entries that are the operator checkout or inside it (other than the
|
|
116
|
+
* snapshot itself, which lives inside it) are removed; every unrelated entry is
|
|
117
|
+
* kept, so an operator's own library path is unaffected.
|
|
118
|
+
*
|
|
119
|
+
* Returns the environment to use. The input is never mutated.
|
|
120
|
+
*/
|
|
121
|
+
export function sanitizeSnapshotPythonPath(env, operatorRoot, snapshotPath) {
|
|
122
|
+
const raw = env.PYTHONPATH;
|
|
123
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
124
|
+
return env;
|
|
125
|
+
const insideOperator = (entry) => {
|
|
126
|
+
const resolved = path.resolve(entry);
|
|
127
|
+
const relative = path.relative(operatorRoot, resolved);
|
|
128
|
+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
|
129
|
+
const toSnapshot = path.relative(snapshotPath, resolved);
|
|
130
|
+
const inSnapshot = toSnapshot === "" || (!toSnapshot.startsWith("..") && !path.isAbsolute(toSnapshot));
|
|
131
|
+
return !inSnapshot;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
};
|
|
135
|
+
const kept = raw
|
|
136
|
+
.split(path.delimiter)
|
|
137
|
+
.filter((entry) => entry.length > 0 && !insideOperator(entry));
|
|
138
|
+
const next = { ...env };
|
|
139
|
+
if (kept.length === 0)
|
|
140
|
+
delete next.PYTHONPATH;
|
|
141
|
+
else
|
|
142
|
+
next.PYTHONPATH = kept.join(path.delimiter);
|
|
143
|
+
return next;
|
|
81
144
|
}
|
|
82
145
|
/** Highest port number a TCP endpoint can occupy. */
|
|
83
146
|
const MAX_TCP_PORT = 65_535;
|
|
@@ -218,7 +281,19 @@ export function buildPlaneObserverEnv(childEnv) {
|
|
|
218
281
|
export function buildPlaneMemberRoster(params) {
|
|
219
282
|
const { context, executors, parentEnv, nodeExecutable } = params;
|
|
220
283
|
const env = buildPlaneChildEnv(parentEnv, context);
|
|
284
|
+
// BAPI-1362 — the working directory the PYTHON members run from: the pinned
|
|
285
|
+
// snapshot when preflight prepared one, otherwise the operator checkout (the
|
|
286
|
+
// unpinned behavior every pre-snapshot caller has). Executor lanes are
|
|
287
|
+
// deliberately NOT moved: they run compiled Node from this repository's
|
|
288
|
+
// gitignored build tree by absolute path, which a snapshot does not contain and
|
|
289
|
+
// a checkout update does not change.
|
|
221
290
|
const cwd = context.repoRoot;
|
|
291
|
+
const pythonCwd = context.snapshot?.path ?? context.repoRoot;
|
|
292
|
+
// Only a snapshot launch needs the import-path guard; an unpinned launch keeps
|
|
293
|
+
// the exact environment it always had.
|
|
294
|
+
const pythonEnv = (base) => context.snapshot
|
|
295
|
+
? sanitizeSnapshotPythonPath(base, context.repoRoot, context.snapshot.path)
|
|
296
|
+
: base;
|
|
222
297
|
// One resolved endpoint for the bind argv, the readiness probe, every
|
|
223
298
|
// executor's `--base-url`, and the child `BAPI_BASE_URL`. Reading the default
|
|
224
299
|
// constants here instead is what would let uvicorn bind one port while the
|
|
@@ -226,7 +301,7 @@ export function buildPlaneMemberRoster(params) {
|
|
|
226
301
|
const endpoint = context.endpoint;
|
|
227
302
|
const server = {
|
|
228
303
|
name: "server",
|
|
229
|
-
command: resolveUvicornExecutable(parentEnv),
|
|
304
|
+
command: resolveUvicornExecutable(parentEnv, params.venv),
|
|
230
305
|
// NO `--reload`, at any layer, ever. `--reload` restarts the worker process
|
|
231
306
|
// on file change and drops in-flight MCP calls, which during a conductor run
|
|
232
307
|
// silently truncates long tool calls the executor is waiting on.
|
|
@@ -237,14 +312,14 @@ export function buildPlaneMemberRoster(params) {
|
|
|
237
312
|
"--port",
|
|
238
313
|
String(endpoint.port),
|
|
239
314
|
],
|
|
240
|
-
cwd,
|
|
315
|
+
cwd: pythonCwd,
|
|
241
316
|
// BAPI-1279 — the server member runs NO scheduler. `main.py` starts one
|
|
242
317
|
// whenever DISABLE_SCHEDULER is unset, and that scheduler ticks the
|
|
243
318
|
// reconciler: the plane would run two reconcilers of its own, and the
|
|
244
319
|
// pre-worker foreign-reconciler guard would see this server's heartbeat
|
|
245
320
|
// and refuse the plane's own worker. The `worker` member below is the one
|
|
246
321
|
// reconciler, exactly as a Heroku web dyno leaves scheduling to `worker:`.
|
|
247
|
-
env: { ...env, DISABLE_SCHEDULER: "true" },
|
|
322
|
+
env: pythonEnv({ ...env, DISABLE_SCHEDULER: "true" }),
|
|
248
323
|
logPath: relativeLogPathFor("server"),
|
|
249
324
|
readiness: {
|
|
250
325
|
kind: "tcp",
|
|
@@ -283,19 +358,19 @@ export function buildPlaneMemberRoster(params) {
|
|
|
283
358
|
*/
|
|
284
359
|
const observer = {
|
|
285
360
|
name: "observer",
|
|
286
|
-
command: resolvePythonExecutable(parentEnv),
|
|
361
|
+
command: resolvePythonExecutable(parentEnv, params.venv),
|
|
287
362
|
args: ["worker.py"],
|
|
288
|
-
cwd,
|
|
289
|
-
env: buildPlaneObserverEnv(env),
|
|
363
|
+
cwd: pythonCwd,
|
|
364
|
+
env: pythonEnv(buildPlaneObserverEnv(env)),
|
|
290
365
|
logPath: relativeLogPathFor("observer"),
|
|
291
366
|
readiness: null,
|
|
292
367
|
};
|
|
293
368
|
const worker = {
|
|
294
369
|
name: "worker",
|
|
295
|
-
command: resolvePythonExecutable(parentEnv),
|
|
370
|
+
command: resolvePythonExecutable(parentEnv, params.venv),
|
|
296
371
|
args: ["worker.py"],
|
|
297
|
-
cwd,
|
|
298
|
-
env,
|
|
372
|
+
cwd: pythonCwd,
|
|
373
|
+
env: pythonEnv(env),
|
|
299
374
|
// The reconciler has no listening socket, so it used to be `readiness: null`
|
|
300
375
|
// — "spawned and still alive", which cannot tell a booting reconciler from
|
|
301
376
|
// one that crashed on its first tick. It is now gated on a FRESH durable
|
package/build/plane/preflight.js
CHANGED
|
@@ -316,7 +316,9 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
316
316
|
execFile: deps.execFile,
|
|
317
317
|
fileExists: (filePath) => fileExists(filePath, deps.fs),
|
|
318
318
|
platform: deps.platform,
|
|
319
|
-
}
|
|
319
|
+
},
|
|
320
|
+
// The code this plane will RUN. The executable still comes from `repoRoot`.
|
|
321
|
+
deps.codeSnapshot?.path ?? repoRoot));
|
|
320
322
|
const existing = await checkExistingPlane(repoRoot, deps);
|
|
321
323
|
record(existing);
|
|
322
324
|
observations.existingPlane = existing.observation;
|
|
@@ -349,6 +351,7 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
349
351
|
runtimeEntrypoint: runtimeEntrypoint.entrypoint,
|
|
350
352
|
bridgeApiKey: credentials.apiKey,
|
|
351
353
|
bridgeCredentialSource: credentials.source,
|
|
354
|
+
...(deps.codeSnapshot !== undefined ? { snapshot: deps.codeSnapshot } : {}),
|
|
352
355
|
},
|
|
353
356
|
};
|
|
354
357
|
}
|
|
@@ -903,14 +906,92 @@ const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `epic-implementer readi
|
|
|
903
906
|
* - A launch-permitting `warning` maps to `warn`, never `fail` and never `skip`,
|
|
904
907
|
* and nothing here feeds back into the preflight's own `ok` calculation.
|
|
905
908
|
*/
|
|
906
|
-
|
|
909
|
+
/**
|
|
910
|
+
* Fixed detail for each descriptor that does not apply to the target (BAPI-1360).
|
|
911
|
+
* Neither string carries a URL, port, or path: the target is only ever named by
|
|
912
|
+
* its kind.
|
|
913
|
+
*/
|
|
914
|
+
const PLANE_NOT_APPLICABLE_DETAIL = {
|
|
915
|
+
remote: "not applicable: the target is a remote Bridge API, not a local plane",
|
|
916
|
+
unresolvable: "not applicable: endpoint could not be resolved from target URL",
|
|
917
|
+
};
|
|
918
|
+
/** BAPI-1397 (F4) — the reason id carried by an own-plane pass row. */
|
|
919
|
+
export const PLANE_OWN_PLANE_REASON_ID = "own_plane";
|
|
920
|
+
/**
|
|
921
|
+
* Fixed own-plane details. Each starts with the stable token `own_plane: ` because
|
|
922
|
+
* the shared readiness row has no reason field; neither names a port, pid, path,
|
|
923
|
+
* or run id.
|
|
924
|
+
*/
|
|
925
|
+
const PLANE_OWN_PLANE_DETAIL = {
|
|
926
|
+
"server-port": `${PLANE_OWN_PLANE_REASON_ID}: the port is served by this repository's plane, which is ` +
|
|
927
|
+
"bound to the run being attached",
|
|
928
|
+
"existing-plane": `${PLANE_OWN_PLANE_REASON_ID}: this repository's live plane is bound to the run being attached`,
|
|
929
|
+
};
|
|
930
|
+
/**
|
|
931
|
+
* Is the live plane this preflight observed the selected run's OWN plane?
|
|
932
|
+
* (BAPI-1397, F4). Pure, and READINESS-ONLY.
|
|
933
|
+
*
|
|
934
|
+
* True only when the existing-plane check's own classification — the same
|
|
935
|
+
* exact-root, live-member reading `classifyLivePlaneForAttach` makes from the ONE
|
|
936
|
+
* manifest read — is `attachable` and the manifest's `epicRunId` equals the
|
|
937
|
+
* selected run id. Nothing here feeds `ok` or a diagnostic, so `plane up` still
|
|
938
|
+
* refuses a live plane exactly as before; only `readiness --epic-run-id` supplies
|
|
939
|
+
* a run id.
|
|
940
|
+
*/
|
|
941
|
+
export function planeIsOwnPlaneForRun(result, epicRunId) {
|
|
942
|
+
if (epicRunId === undefined || epicRunId.trim() === "")
|
|
943
|
+
return false;
|
|
944
|
+
const existing = result?.observations?.existingPlane;
|
|
945
|
+
return existing?.kind === "attachable" && existing.manifest.epicRunId === epicRunId;
|
|
946
|
+
}
|
|
947
|
+
export function mapPlanePreflightToReadinessChecks(result,
|
|
948
|
+
/**
|
|
949
|
+
* An EXPLICIT statement that the plane checks do not apply to this target. It is
|
|
950
|
+
* the only way a `skip` is produced here: a missing preflight result with no such
|
|
951
|
+
* input stays the failure it always was, so an unrun probe is never mistaken for
|
|
952
|
+
* an inapplicable one.
|
|
953
|
+
*/
|
|
954
|
+
notApplicable,
|
|
955
|
+
/**
|
|
956
|
+
* BAPI-1397 (F4) — the run being attached (`readiness --epic-run-id`). When the
|
|
957
|
+
* observed live plane is that run's own plane, its occupied port and its live
|
|
958
|
+
* manifest are reported as `pass` rows with the `own_plane` reason instead of two
|
|
959
|
+
* `fail` rows. Absent or different, every row maps exactly as before.
|
|
960
|
+
*/
|
|
961
|
+
options = {}) {
|
|
962
|
+
if (notApplicable !== undefined) {
|
|
963
|
+
return PLANE_READINESS_DESCRIPTORS.map(({ id, label }) => createReadinessCheck({
|
|
964
|
+
id: `plane.${id}`,
|
|
965
|
+
source: "plane",
|
|
966
|
+
label,
|
|
967
|
+
status: "skip",
|
|
968
|
+
detail: PLANE_NOT_APPLICABLE_DETAIL[notApplicable],
|
|
969
|
+
}));
|
|
970
|
+
}
|
|
907
971
|
const byId = new Map();
|
|
908
972
|
for (const outcome of result?.outcomes ?? []) {
|
|
909
973
|
if (!byId.has(outcome.id))
|
|
910
974
|
byId.set(outcome.id, outcome);
|
|
911
975
|
}
|
|
976
|
+
const ownPlane = planeIsOwnPlaneForRun(result, options.epicRunId);
|
|
912
977
|
return PLANE_READINESS_DESCRIPTORS.map(({ id, label }) => {
|
|
913
978
|
const outcome = byId.get(id);
|
|
979
|
+
// The occupied port is only reclassified when the probe actually saw it
|
|
980
|
+
// occupied; a free port is already a pass and an indeterminate probe keeps
|
|
981
|
+
// its warning. A pass carries no remediation.
|
|
982
|
+
if (ownPlane &&
|
|
983
|
+
outcome &&
|
|
984
|
+
outcome.status === "fail" &&
|
|
985
|
+
(id === "existing-plane" ||
|
|
986
|
+
(id === "server-port" && result?.observations?.serverPort === "connected"))) {
|
|
987
|
+
return createReadinessCheck({
|
|
988
|
+
id: `plane.${id}`,
|
|
989
|
+
source: "plane",
|
|
990
|
+
label: outcome.label || label,
|
|
991
|
+
status: "pass",
|
|
992
|
+
detail: PLANE_OWN_PLANE_DETAIL[id],
|
|
993
|
+
});
|
|
994
|
+
}
|
|
914
995
|
if (!outcome) {
|
|
915
996
|
return createReadinessCheck({
|
|
916
997
|
id: `plane.${id}`,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actual working directory of a live process (BAPI-1362).
|
|
3
|
+
*
|
|
4
|
+
* The base-worktree fast-forward guard needs to know whether a plane member is
|
|
5
|
+
* running out of a given checkout. A v4 manifest records each member's `cwd`, but
|
|
6
|
+
* a legacy manifest records nothing, so the only evidence is the process itself.
|
|
7
|
+
*
|
|
8
|
+
* Closed result, never a throw, and never raw command output: the only string a
|
|
9
|
+
* caller can receive is a path that passed {@link isCanonicalAbsolutePath}.
|
|
10
|
+
* Anything this function cannot establish is `unverifiable`, and the guard reads
|
|
11
|
+
* that as "unsafe to merge", not as "somewhere else".
|
|
12
|
+
*/
|
|
13
|
+
import { isCanonicalAbsolutePath } from "./manifest.js";
|
|
14
|
+
/** Positive integer pids only; anything else can never be placed in an argv. */
|
|
15
|
+
function isUsablePid(pid) {
|
|
16
|
+
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse `lsof -Fn` field output and return the path on its `n` line.
|
|
20
|
+
*
|
|
21
|
+
* Exactly one `n` line is accepted. Zero or several mean the output is not what
|
|
22
|
+
* this code understands, which is `unverifiable` rather than a guess.
|
|
23
|
+
*/
|
|
24
|
+
export function parseLsofCwd(stdout) {
|
|
25
|
+
const names = stdout
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.filter((line) => line.startsWith("n"))
|
|
28
|
+
.map((line) => line.slice(1));
|
|
29
|
+
if (names.length !== 1)
|
|
30
|
+
return null;
|
|
31
|
+
return isCanonicalAbsolutePath(names[0]) ? names[0] : null;
|
|
32
|
+
}
|
|
33
|
+
export async function readProcessCwd(pid, deps) {
|
|
34
|
+
if (!isUsablePid(pid))
|
|
35
|
+
return { kind: "unverifiable" };
|
|
36
|
+
try {
|
|
37
|
+
if (deps.platform === "linux") {
|
|
38
|
+
const target = await deps.readlink(`/proc/${pid}/cwd`);
|
|
39
|
+
return isCanonicalAbsolutePath(target) ? { kind: "cwd", cwd: target } : { kind: "unverifiable" };
|
|
40
|
+
}
|
|
41
|
+
if (deps.platform === "darwin") {
|
|
42
|
+
const result = await deps.execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
|
|
43
|
+
if (!result.ok)
|
|
44
|
+
return { kind: "unverifiable" };
|
|
45
|
+
const cwd = parseLsofCwd(result.stdout);
|
|
46
|
+
return cwd === null ? { kind: "unverifiable" } : { kind: "cwd", cwd };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* every failure is the same closed answer */
|
|
51
|
+
}
|
|
52
|
+
return { kind: "unverifiable" };
|
|
53
|
+
}
|
|
54
|
+
/** Production bindings. The only place this module touches the platform. */
|
|
55
|
+
export async function createProcessCwdDeps() {
|
|
56
|
+
const { readlink } = await import("fs/promises");
|
|
57
|
+
const { execFile } = await import("child_process");
|
|
58
|
+
return {
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
readlink: (linkPath) => readlink(linkPath),
|
|
61
|
+
execFile: (file, args) => new Promise((resolve) => {
|
|
62
|
+
execFile(file, args, { shell: false, windowsHide: true, timeout: 5_000 }, (error, stdout) => {
|
|
63
|
+
resolve(error ? { ok: false } : { ok: true, stdout: String(stdout) });
|
|
64
|
+
});
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|