@bridge_gpt/mcp-server 0.2.50 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed pre-spawn env-file strip for an executor worktree (BAPI-1019, A.3).
|
|
3
|
+
*
|
|
4
|
+
* Architecture Miss 28: a v2 conductor worker ran an allowed `pytest` whose
|
|
5
|
+
* harness truncated the operator's real operational database. The worker could
|
|
6
|
+
* reach that database because its worktree carried a copy of the operator's
|
|
7
|
+
* `.env`, delivered by Worktrunk's `copy-ignored` step. `.config/wt.toml` now
|
|
8
|
+
* excludes `.env*` from that copy, which removes the cause — but Worktrunk runs
|
|
9
|
+
* `post-start` hooks in the BACKGROUND after `wt switch` returns, and any local
|
|
10
|
+
* tooling an operator adds can write into a worktree at any time. A source-level
|
|
11
|
+
* exclusion therefore cannot be the whole answer: the executor has to check, at
|
|
12
|
+
* the last possible moment, that the worktree it is about to hand a worker is
|
|
13
|
+
* actually free of environment files.
|
|
14
|
+
*
|
|
15
|
+
* That is this module. It runs as the FINAL step before `deps.spawnProcess` and
|
|
16
|
+
* it fails CLOSED — following `StaleArtifactCleanupFailed`, not the fail-open
|
|
17
|
+
* deny-layer posture. "Could not confirm clean" and "confirmed clean" must be
|
|
18
|
+
* distinguishable outcomes, and only the second one is allowed to spawn.
|
|
19
|
+
*
|
|
20
|
+
* THE SINGLE MOST IMPORTANT DETAIL: this module uses `lstat` and `unlink` and
|
|
21
|
+
* NEVER follows a symbolic link. An interactive worktree's `.env` is a symlink
|
|
22
|
+
* into the main checkout (see `../env-file-link.ts`), and if an entry like that
|
|
23
|
+
* ever appeared in an executor worktree, resolving it and unlinking the RESOLVED
|
|
24
|
+
* target would delete the operator's real `.env`. `unlink` removes the directory
|
|
25
|
+
* entry itself, so a link is removed as a link. There is deliberately no `stat`,
|
|
26
|
+
* `realpath`, `readlink`, or `readFile` in the dependency surface — the guarantee
|
|
27
|
+
* is enforced by what the module cannot reach, not by remembering not to call it.
|
|
28
|
+
*
|
|
29
|
+
* The re-list after removal is what makes the two outcomes tellable apart. An
|
|
30
|
+
* unreadable directory, a failed `lstat`, a failed `unlink`, or a surviving entry
|
|
31
|
+
* all produce `ok: false`; only a second listing that shows no matching entry
|
|
32
|
+
* produces `ok: true`.
|
|
33
|
+
*
|
|
34
|
+
* REDACTION. A failure returns a fixed CATEGORY and nothing else — no filename,
|
|
35
|
+
* no path, no resolved target, no exception text. This value reaches the job row
|
|
36
|
+
* and the executor's stderr, and a worktree path contains an operator's username.
|
|
37
|
+
* Successful `removed` names are safe by construction: they are basenames drawn
|
|
38
|
+
* from a fixed pattern, and knowing that `.env` was removed is the diagnostic. See
|
|
39
|
+
* `secretFreeErrorMessage()` / `ERROR_MESSAGE_MAX_CHARS` in `job-errors.ts` for
|
|
40
|
+
* the bounding posture these strings satisfy.
|
|
41
|
+
*/
|
|
42
|
+
import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
|
|
43
|
+
/**
|
|
44
|
+
* True for `.env` and anything under the `.env.` prefix.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately WIDER than the interactive linker's closed two-name list: removing
|
|
47
|
+
* too much from a worker worktree is harmless, and an operator's `.env.staging`
|
|
48
|
+
* is exactly as dangerous as their `.env`. It is still a PREFIX rule, not a
|
|
49
|
+
* substring one — `.envrc` (direnv) and `.envx` do not match, because they are
|
|
50
|
+
* not environment files this contract is about and silently deleting an
|
|
51
|
+
* operator's direnv config would be a surprise well outside this ticket.
|
|
52
|
+
*/
|
|
53
|
+
export function isWorkerEnvFileName(name) {
|
|
54
|
+
return name === ".env" || name.startsWith(".env.");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Strip every `.env` / `.env.*` entry from a worktree ROOT and prove it worked.
|
|
58
|
+
*
|
|
59
|
+
* Only the root is examined. A nested `.env` (a fixture under `tests/`, a
|
|
60
|
+
* vendored example) is not what reaches a process's environment loader, and
|
|
61
|
+
* walking the tree would turn a fast pre-spawn gate into an unbounded traversal
|
|
62
|
+
* of a whole checkout.
|
|
63
|
+
*/
|
|
64
|
+
export async function stripWorkerEnvFiles(worktreePath, deps) {
|
|
65
|
+
const pathApi = pathApiForExecutorPlatform(deps.platform ?? process.platform);
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = await deps.readdir(worktreePath);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Cannot even see the directory ⇒ cannot confirm it is clean. Refuse.
|
|
72
|
+
return { ok: false, category: "listing_failed" };
|
|
73
|
+
}
|
|
74
|
+
// Sorted so a successful `removed` list is deterministic and two job rows for
|
|
75
|
+
// the same condition render identically.
|
|
76
|
+
const matches = entries.filter(isWorkerEnvFileName).sort();
|
|
77
|
+
for (const name of matches) {
|
|
78
|
+
const target = pathApi.join(worktreePath, name);
|
|
79
|
+
try {
|
|
80
|
+
// Non-following metadata. The result is deliberately unused for a decision:
|
|
81
|
+
// a regular file and a symlink are both removed the same way, by `unlink`.
|
|
82
|
+
// The call is here so an entry that cannot be inspected refuses the spawn
|
|
83
|
+
// instead of being unlinked blind.
|
|
84
|
+
await deps.lstat(target);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { ok: false, category: "metadata_failed" };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await deps.unlink(target);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Includes the directory case (`EISDIR`/`EPERM`): a `.env` that is a
|
|
94
|
+
// DIRECTORY is not removable by `unlink`, and this guard deliberately does
|
|
95
|
+
// not escalate to a recursive delete inside a worktree.
|
|
96
|
+
return { ok: false, category: "unlink_failed" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The confirmation half. Without this, "we called unlink" would be reported as
|
|
100
|
+
// "the worktree is clean", which is exactly the claim this gate must not make
|
|
101
|
+
// on faith.
|
|
102
|
+
let remaining;
|
|
103
|
+
try {
|
|
104
|
+
remaining = await deps.readdir(worktreePath);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { ok: false, category: "verification_listing_failed" };
|
|
108
|
+
}
|
|
109
|
+
if (remaining.some(isWorkerEnvFileName)) {
|
|
110
|
+
return { ok: false, category: "entry_survived" };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, removed: matches };
|
|
113
|
+
}
|
package/build/executor/env.js
CHANGED
|
@@ -33,13 +33,26 @@
|
|
|
33
33
|
* executor must not reach a worker and silently change which configuration —
|
|
34
34
|
* and which MCP registrations — the CLI resolves.
|
|
35
35
|
*
|
|
36
|
+
* BAPI-1019: this builder also injects four SENTINEL DATABASE literals
|
|
37
|
+
* (`DATABASE_URL`, `POSTGRES_DATABASE`, `POSTGRES_DB`, `PGDATABASE`), all naming a
|
|
38
|
+
* database that does not exist. Architecture Miss 28 was a worker that ran an
|
|
39
|
+
* allowed `pytest` whose harness truncated the operator's real operational
|
|
40
|
+
* database; it could name that database because a copied `.env` reached its
|
|
41
|
+
* worktree and nothing in the worker environment said otherwise. The env-file
|
|
42
|
+
* strip in `env-file-guard.ts` removes the file, and these constants are the
|
|
43
|
+
* SECOND layer: code inside a worker that resolves a database from the
|
|
44
|
+
* environment resolves a name that connects to nothing. The two layers are
|
|
45
|
+
* independent on purpose — the harness calls `load_dotenv(override=False)`, so a
|
|
46
|
+
* value already in the environment WINS over a `.env` entry, which is what makes
|
|
47
|
+
* the sentinel effective even if a file ever survived the strip.
|
|
48
|
+
*
|
|
36
49
|
* The `mcp-invoke` shim (a separate process) resolves `BAPI_API_KEY` itself at
|
|
37
50
|
* call time from the operator's real user-scoped credential store, exactly as it
|
|
38
51
|
* did before BAPI-731, so this worker environment never carries it. The model is
|
|
39
52
|
* still denied direct reads of that store by the deny layer
|
|
40
53
|
* (`Read(~/.config/bridge/**)` in `permissions.ts`).
|
|
41
54
|
*/
|
|
42
|
-
import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
55
|
+
import { PR_BASE_BRANCH_ENV_VAR, WORKER_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
43
56
|
import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
|
|
44
57
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
45
58
|
const ALLOWED_ENV_KEYS = [
|
|
@@ -56,6 +69,36 @@ const ALLOWED_ENV_KEYS = [
|
|
|
56
69
|
"TERM",
|
|
57
70
|
"NO_COLOR",
|
|
58
71
|
];
|
|
72
|
+
/**
|
|
73
|
+
* The database name every worker resolves, and it exists nowhere (BAPI-1019).
|
|
74
|
+
*
|
|
75
|
+
* A NAME rather than an empty value, deliberately: an unset `POSTGRES_DB` makes a
|
|
76
|
+
* client fall back to a default (often the connecting user's name, which on a
|
|
77
|
+
* developer host is a real database), while a name nothing has ever created fails
|
|
78
|
+
* at connect and says so. The failure text carries this string, which is what the
|
|
79
|
+
* harness drill asserts on — proving the name reached the driver rather than
|
|
80
|
+
* merely that a connection failed.
|
|
81
|
+
*/
|
|
82
|
+
export const WORKER_SENTINEL_DATABASE_NAME = "bridge_worker_no_database";
|
|
83
|
+
/**
|
|
84
|
+
* The sentinel connection URL. Points at port 1 on loopback — a privileged port
|
|
85
|
+
* nothing binds — so the failure is immediate and local rather than a DNS lookup
|
|
86
|
+
* or a timeout against something real.
|
|
87
|
+
*/
|
|
88
|
+
export const WORKER_SENTINEL_DATABASE_URL = `postgresql://${WORKER_SENTINEL_DATABASE_NAME}@127.0.0.1:1/${WORKER_SENTINEL_DATABASE_NAME}`;
|
|
89
|
+
/**
|
|
90
|
+
* Every environment key that names a database target, in the order they are set.
|
|
91
|
+
*
|
|
92
|
+
* `DATABASE_URL` is listed first because it WINS over the `POSTGRES_*` trio in
|
|
93
|
+
* `db/utils.py`; the other three are set anyway so a code path that reads only
|
|
94
|
+
* the discrete parts is covered too.
|
|
95
|
+
*/
|
|
96
|
+
export const WORKER_SENTINEL_DATABASE_ENV_KEYS = [
|
|
97
|
+
"DATABASE_URL",
|
|
98
|
+
"POSTGRES_DATABASE",
|
|
99
|
+
"POSTGRES_DB",
|
|
100
|
+
"PGDATABASE",
|
|
101
|
+
];
|
|
59
102
|
/** Substrings that mark a key as secret-bearing (case-insensitive). */
|
|
60
103
|
const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
|
|
61
104
|
/**
|
|
@@ -101,6 +144,24 @@ export const EXPLICIT_DENY_KEYS = [
|
|
|
101
144
|
// decision, and inheriting one would route an unscoped worker's plan and review
|
|
102
145
|
// at another epic's shadow index while nothing in the job said so.
|
|
103
146
|
INDEX_SCOPE_ENV_VAR,
|
|
147
|
+
// BAPI-1020: the worker's own branch follows the same rule, and for this key the
|
|
148
|
+
// rule is the guard's integrity rather than a research-scope mistake. The
|
|
149
|
+
// deterministic PreToolUse guard decides "is this push destination my own
|
|
150
|
+
// branch?" by comparing against this value, so an operator's ambient
|
|
151
|
+
// `BAPI_WORKER_BRANCH` reaching a worker would let the environment redefine what
|
|
152
|
+
// the guard treats as safe. Absent from ALLOWED_ENV_KEYS so it is never copied
|
|
153
|
+
// from `parentEnv`, and denied EXPLICITLY here so the answer is local and
|
|
154
|
+
// auditable rather than inferred from an omission.
|
|
155
|
+
WORKER_BRANCH_ENV_VAR,
|
|
156
|
+
// BAPI-1019: the four database keys follow the BAPI_INDEX_SCOPE precedent
|
|
157
|
+
// exactly — absent from ALLOWED_ENV_KEYS so they are never copied from
|
|
158
|
+
// `parentEnv`, and denied EXPLICITLY here so an operator's ambient value can
|
|
159
|
+
// never win over the sentinel constants the builder sets. Denying a key the
|
|
160
|
+
// builder also sets is not redundant: the deny list is what makes
|
|
161
|
+
// `isExecutorEnvKeyAllowed` answer "no" for these names locally and
|
|
162
|
+
// auditably, rather than relying on a reader noticing they are missing from
|
|
163
|
+
// the allowlist.
|
|
164
|
+
...WORKER_SENTINEL_DATABASE_ENV_KEYS,
|
|
104
165
|
];
|
|
105
166
|
/**
|
|
106
167
|
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
@@ -140,10 +201,26 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
|
|
|
140
201
|
}
|
|
141
202
|
env.BRIDGE_SKIP_PREPUSH = "1";
|
|
142
203
|
env.MAX_MCP_OUTPUT_TOKENS = "50000";
|
|
204
|
+
// BAPI-1019 — the sentinel database target, set for EVERY job type and every
|
|
205
|
+
// adapter, from constants and never from `parentEnv`. Set here rather than in
|
|
206
|
+
// an adapter because "a worker cannot name a real database" is a property of
|
|
207
|
+
// being a worker, not of being a Claude worker.
|
|
208
|
+
env.DATABASE_URL = WORKER_SENTINEL_DATABASE_URL;
|
|
209
|
+
env.POSTGRES_DATABASE = WORKER_SENTINEL_DATABASE_NAME;
|
|
210
|
+
env.POSTGRES_DB = WORKER_SENTINEL_DATABASE_NAME;
|
|
211
|
+
env.PGDATABASE = WORKER_SENTINEL_DATABASE_NAME;
|
|
143
212
|
if (typeof options.effectiveBaseBranch === "string" &&
|
|
144
213
|
options.effectiveBaseBranch.length > 0) {
|
|
145
214
|
env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
|
|
146
215
|
}
|
|
216
|
+
// BAPI-1020 — the worker's own branch, set ONLY from the explicit job value and
|
|
217
|
+
// ONLY when it is a non-empty string. An empty or absent value adds no key at
|
|
218
|
+
// all, so an unscoped job's environment stays byte-identical to the
|
|
219
|
+
// pre-BAPI-1020 one rather than carrying an empty string the guard would have to
|
|
220
|
+
// special-case.
|
|
221
|
+
if (typeof options.workerBranch === "string" && options.workerBranch.length > 0) {
|
|
222
|
+
env[WORKER_BRANCH_ENV_VAR] = options.workerBranch;
|
|
223
|
+
}
|
|
147
224
|
// BAPI-844: the index-scope declaration, set ONLY from the explicit option and
|
|
148
225
|
// only after the shared shape check. A malformed explicit value throws here —
|
|
149
226
|
// before an environment exists — rather than being dropped: silently omitting it
|
|
@@ -79,6 +79,15 @@ export async function runHeartbeatLoop(params) {
|
|
|
79
79
|
killOwnedProcess(proc, "server_stop");
|
|
80
80
|
return { kind: "server_stop" };
|
|
81
81
|
}
|
|
82
|
+
if (result.outcome === "auth_fatal") {
|
|
83
|
+
// BAPI-1021 (AC-5): bypass the dead-man wait entirely — an auth failure
|
|
84
|
+
// will never resolve itself by waiting out `deadmanMs`. Deliberately does
|
|
85
|
+
// NOT reset `lastSuccessfulHeartbeatAt`, set `ownership.abandoned`/
|
|
86
|
+
// `abandonReason` (this is not a lost/stale claim), or invoke worktree
|
|
87
|
+
// reset/recreation/cleanup: the current job drains normally while the
|
|
88
|
+
// runner's process-level handler stops future claiming.
|
|
89
|
+
return { kind: "auth_fatal", httpStatus: result.authHttpStatus ?? 401 };
|
|
90
|
+
}
|
|
82
91
|
if (result.outcome === "updated") {
|
|
83
92
|
ownership.lastSuccessfulHeartbeatAt = deps.now();
|
|
84
93
|
return null;
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor `/executor/jobs/*` protocol HTTP client (BAPI-534, TDD §7, §10).
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the T2 protocol behind an injected `fetch` boundary with correct
|
|
5
|
+
* fencing-token semantics. The claim response `repo_name` and `claim_token` are
|
|
6
|
+
* echoed on every heartbeat/complete/fail (the server mutation guards are
|
|
7
|
+
* repo-bound and fenced on `claim_token`). The `X-API-Key` header value is never
|
|
8
|
+
* logged and never included in any error string.
|
|
9
|
+
*/
|
|
10
|
+
import { RECONCILER_LIVENESS_VALUES } from "./types.js";
|
|
11
|
+
import { readEpicRunCompletionState, } from "../conductor/bridge-api-client.js";
|
|
1
12
|
/** Bound a server-provided diagnostic snippet included in error strings. */
|
|
2
13
|
function boundedDetail(text) {
|
|
3
14
|
const trimmed = (text ?? "").trim();
|
|
@@ -39,13 +50,6 @@ function parseStopRequested(bodyText) {
|
|
|
39
50
|
* (BAPI-871). Exported so the client and its tests name it once.
|
|
40
51
|
*/
|
|
41
52
|
export const RECONCILER_LIVENESS_HEADER = "X-BAPI-Reconciler-Liveness";
|
|
42
|
-
/** The only values the server is documented to send. */
|
|
43
|
-
const RECONCILER_LIVENESS_VALUES = [
|
|
44
|
-
"fresh",
|
|
45
|
-
"stale",
|
|
46
|
-
"never_seen",
|
|
47
|
-
"unknown",
|
|
48
|
-
];
|
|
49
53
|
/**
|
|
50
54
|
* Read the liveness header off a claim response, admitting ONLY documented
|
|
51
55
|
* values.
|
|
@@ -76,17 +80,43 @@ export function parseReconcilerLivenessHeader(headers) {
|
|
|
76
80
|
/**
|
|
77
81
|
* Map a mutation (heartbeat/complete/fail) HTTP response onto the fencing
|
|
78
82
|
* outcome. `allowInvalidResult` maps 422 to `invalid_job_result` (complete only).
|
|
83
|
+
*
|
|
84
|
+
* BAPI-1021 (AC-5): 401/403 map to `auth_fatal` with the exact status attached,
|
|
85
|
+
* BEFORE the generic `fatal_http_error` fallback — the response body is never
|
|
86
|
+
* retained on this path (never logged, never echoed into the returned metadata).
|
|
79
87
|
*/
|
|
80
88
|
function mapMutationResponse(status, bodyText, allowInvalidResult) {
|
|
81
89
|
if (status === 409)
|
|
82
|
-
return "stale_claim";
|
|
90
|
+
return { outcome: "stale_claim" };
|
|
83
91
|
if (status === 503)
|
|
84
|
-
return "retry_later";
|
|
92
|
+
return { outcome: "retry_later" };
|
|
85
93
|
if (status === 422 && allowInvalidResult)
|
|
86
|
-
return "invalid_job_result";
|
|
94
|
+
return { outcome: "invalid_job_result" };
|
|
87
95
|
if (status >= 200 && status < 300 && isOkBody(bodyText))
|
|
88
|
-
return "updated";
|
|
89
|
-
|
|
96
|
+
return { outcome: "updated" };
|
|
97
|
+
if (status === 401 || status === 403)
|
|
98
|
+
return { outcome: "auth_fatal", authHttpStatus: status };
|
|
99
|
+
return { outcome: "fatal_http_error" };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Adapt the executor's injected `FetchLike` transport to the narrower
|
|
103
|
+
* `ConductorFetch` shape the shared read-only Bridge API GET helper expects
|
|
104
|
+
* (BAPI-1010). GET-only; deliberately ignores the caller's `AbortSignal` —
|
|
105
|
+
* `FetchLike`'s production implementation (`createDefaultExecutorDeps`)
|
|
106
|
+
* already wraps every request in its own `AbortController` timeout, so no
|
|
107
|
+
* request is ever left uncancelled by discarding this one.
|
|
108
|
+
*/
|
|
109
|
+
function toConductorGetFetch(fetchLike) {
|
|
110
|
+
return async (url, init) => {
|
|
111
|
+
const requestInit = { method: "GET", headers: init.headers };
|
|
112
|
+
const res = await fetchLike(url, requestInit);
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
return {
|
|
115
|
+
ok: res.status >= 200 && res.status < 300,
|
|
116
|
+
status: res.status,
|
|
117
|
+
json: async () => JSON.parse(text),
|
|
118
|
+
};
|
|
119
|
+
};
|
|
90
120
|
}
|
|
91
121
|
export function createExecutorHttpClient(config) {
|
|
92
122
|
const base = config.baseUrl.replace(/\/+$/, "") + "/executor/jobs";
|
|
@@ -119,7 +149,7 @@ export function createExecutorHttpClient(config) {
|
|
|
119
149
|
catch {
|
|
120
150
|
// Transient network error — treat as fatal for a single mutation attempt;
|
|
121
151
|
// the caller's retry/dead-man logic decides whether to keep the worker alive.
|
|
122
|
-
return "fatal_http_error";
|
|
152
|
+
return { outcome: "fatal_http_error" };
|
|
123
153
|
}
|
|
124
154
|
}
|
|
125
155
|
return {
|
|
@@ -158,7 +188,18 @@ export function createExecutorHttpClient(config) {
|
|
|
158
188
|
return { kind: "fatal", error: "claim returned invalid JSON" };
|
|
159
189
|
}
|
|
160
190
|
}
|
|
161
|
-
if (status === 401 || status === 403
|
|
191
|
+
if (status === 401 || status === 403) {
|
|
192
|
+
// BAPI-1021 (AC-5): carry the auth category + exact status alongside the
|
|
193
|
+
// existing error string, distinguishing this from the non-auth fatal 422
|
|
194
|
+
// contract response below without parsing `error`.
|
|
195
|
+
return {
|
|
196
|
+
kind: "fatal",
|
|
197
|
+
error: `claim rejected (HTTP ${status}): ${boundedDetail(text)}`,
|
|
198
|
+
authCategory: "auth_fatal",
|
|
199
|
+
authHttpStatus: status,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (status === 422) {
|
|
162
203
|
return { kind: "fatal", error: `claim rejected (HTTP ${status}): ${boundedDetail(text)}` };
|
|
163
204
|
}
|
|
164
205
|
// 5xx and any other unexpected status are retryable.
|
|
@@ -171,13 +212,14 @@ export function createExecutorHttpClient(config) {
|
|
|
171
212
|
let status;
|
|
172
213
|
let text;
|
|
173
214
|
try {
|
|
174
|
-
const
|
|
215
|
+
const body = {
|
|
175
216
|
repo_name: job.repo_name,
|
|
176
217
|
claim_token: job.claim_token,
|
|
177
218
|
local_commit_count: payload.local_commit_count,
|
|
178
219
|
last_commit_sha: payload.last_commit_sha,
|
|
179
220
|
telemetry: payload.telemetry,
|
|
180
|
-
}
|
|
221
|
+
};
|
|
222
|
+
const res = await post(`/${job.id}/heartbeat`, body, job.repo_name);
|
|
181
223
|
status = res.status;
|
|
182
224
|
text = res.text;
|
|
183
225
|
}
|
|
@@ -186,8 +228,10 @@ export function createExecutorHttpClient(config) {
|
|
|
186
228
|
// caller's dead-man logic decides whether to keep the worker alive.
|
|
187
229
|
return { outcome: "fatal_http_error" };
|
|
188
230
|
}
|
|
189
|
-
const
|
|
190
|
-
const result = { outcome };
|
|
231
|
+
const mapped = mapMutationResponse(status, text, false);
|
|
232
|
+
const result = { outcome: mapped.outcome };
|
|
233
|
+
if (mapped.authHttpStatus !== undefined)
|
|
234
|
+
result.authHttpStatus = mapped.authHttpStatus;
|
|
191
235
|
if (parseStopRequested(text))
|
|
192
236
|
result.stop_requested = true;
|
|
193
237
|
return result;
|
|
@@ -211,7 +255,7 @@ export function createExecutorHttpClient(config) {
|
|
|
211
255
|
}
|
|
212
256
|
},
|
|
213
257
|
complete(job, completion) {
|
|
214
|
-
|
|
258
|
+
const body = {
|
|
215
259
|
repo_name: job.repo_name,
|
|
216
260
|
claim_token: job.claim_token,
|
|
217
261
|
job_type: completion.job_type,
|
|
@@ -222,10 +266,11 @@ export function createExecutorHttpClient(config) {
|
|
|
222
266
|
local_commit_count: completion.local_commit_count,
|
|
223
267
|
last_commit_sha: completion.last_commit_sha,
|
|
224
268
|
telemetry: completion.telemetry,
|
|
225
|
-
}
|
|
269
|
+
};
|
|
270
|
+
return mutate(`/${job.id}/complete`, body, true, job.repo_name);
|
|
226
271
|
},
|
|
227
272
|
fail(job, failure) {
|
|
228
|
-
|
|
273
|
+
const body = {
|
|
229
274
|
repo_name: job.repo_name,
|
|
230
275
|
claim_token: job.claim_token,
|
|
231
276
|
error_kind: failure.error_kind,
|
|
@@ -234,7 +279,30 @@ export function createExecutorHttpClient(config) {
|
|
|
234
279
|
local_commit_count: failure.local_commit_count,
|
|
235
280
|
last_commit_sha: failure.last_commit_sha,
|
|
236
281
|
telemetry: failure.telemetry,
|
|
237
|
-
}
|
|
282
|
+
};
|
|
283
|
+
return mutate(`/${job.id}/fail`, body, false, job.repo_name);
|
|
284
|
+
},
|
|
285
|
+
/**
|
|
286
|
+
* Read-only epic-run completion-state projection (BAPI-1010). Delegates
|
|
287
|
+
* URL construction, timeout handling, response validation, and failure
|
|
288
|
+
* sanitization entirely to {@link readEpicRunCompletionState} — this
|
|
289
|
+
* method only resolves the repo-scoped access and adapts the transport.
|
|
290
|
+
* Never throws: `readEpicRunCompletionState` is itself non-throwing, and
|
|
291
|
+
* the try/catch below is belt-and-braces so a future change there can
|
|
292
|
+
* never surface an exception through this public contract.
|
|
293
|
+
*/
|
|
294
|
+
async readEpicRunState(epicRunId, repoName) {
|
|
295
|
+
const access = {
|
|
296
|
+
repoName,
|
|
297
|
+
apiKey: keyForRepo(repoName),
|
|
298
|
+
baseUrl: config.baseUrl,
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
return await readEpicRunCompletionState(access, epicRunId, toConductorGetFetch(config.fetch));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return { ok: false, reason: "network" };
|
|
305
|
+
}
|
|
238
306
|
},
|
|
239
307
|
};
|
|
240
308
|
}
|
|
@@ -86,6 +86,14 @@
|
|
|
86
86
|
* alternative to reporting it is defaulting to Claude's behavior for an agent
|
|
87
87
|
* nobody implemented, which would spawn a real worker under containment the
|
|
88
88
|
* operator never declared.
|
|
89
|
+
* - `ContractError.WorkerEnvFilePresent` — the worktree could not be CONFIRMED
|
|
90
|
+
* free of `.env` / `.env.*` entries at the last moment before spawn
|
|
91
|
+
* (BAPI-1019). An operator environment file inside a worker's worktree is how
|
|
92
|
+
* Architecture Miss 28 happened: the worker resolved a real database from one
|
|
93
|
+
* and a test harness then truncated that database. Fail-CLOSED, pre-spawn, and
|
|
94
|
+
* NON-RETRYABLE — the condition is a property of the HOST's worktree tooling
|
|
95
|
+
* rather than of the ticket, so the next job on the same host reproduces it
|
|
96
|
+
* identically; see `env-file-guard.ts`.
|
|
89
97
|
*
|
|
90
98
|
* These kinds are deliberately DISTINCT from each other so the refusals stay
|
|
91
99
|
* tellable apart in `executor_jobs.error_kind`.
|
|
@@ -123,6 +131,14 @@ export const ExecutorAdapterUnavailable = "ContractError.ExecutorAdapterUnavaila
|
|
|
123
131
|
* alternative is running an attempt that may read a stale verdict as its own.
|
|
124
132
|
*/
|
|
125
133
|
export const StaleArtifactCleanupFailed = "ContractError.StaleArtifactCleanupFailed";
|
|
134
|
+
/**
|
|
135
|
+
* BAPI-1019 (A.4): the worktree still held — or could not be proven free of — an
|
|
136
|
+
* operator environment file at the spawn boundary. Sits beside
|
|
137
|
+
* {@link PreSpawnVerification} and {@link StaleArtifactCleanupFailed} because it
|
|
138
|
+
* is the same shape: a pre-spawn assertion about the worktree that refuses rather
|
|
139
|
+
* than proceeding on an unverified premise.
|
|
140
|
+
*/
|
|
141
|
+
export const WorkerEnvFilePresent = "ContractError.WorkerEnvFilePresent";
|
|
126
142
|
/** Rendered in place of an empty name list, so "none" is never ambiguous. */
|
|
127
143
|
export const EMPTY_MCP_SERVER_NAME_MARKER = "none";
|
|
128
144
|
/** Bound the rendered name lists so a pathological registration cannot spam a job row. */
|
|
@@ -207,6 +223,25 @@ export const WORKTREE_BUSY_MESSAGE = "another executor job already holds this ti
|
|
|
207
223
|
"second claim is refused rather than allowed in. The refusal is expected when two jobs exist " +
|
|
208
224
|
"for one ticket; the job that holds the worktree continues normally, and this one is safe to " +
|
|
209
225
|
"retry once it finishes. See the executor's stderr for the conflicting job id.";
|
|
226
|
+
/**
|
|
227
|
+
* The fixed message posted with {@link WorkerEnvFilePresent} (BAPI-1019).
|
|
228
|
+
*
|
|
229
|
+
* FIXED and identity-free, following {@link WORKTREE_BUSY_MESSAGE} and
|
|
230
|
+
* {@link WORKER_STARTUP_FATAL_MESSAGE}: it states what was OBSERVED (an
|
|
231
|
+
* environment file was present in the worktree at spawn and could not be
|
|
232
|
+
* confirmed removed) and names the remediation, and it carries no worktree path,
|
|
233
|
+
* no username, no discovered filename, no link target, no file content, and no
|
|
234
|
+
* exception text. This string is stored on the job row and read back by anyone
|
|
235
|
+
* with access to the run; the attributable detail (job id, guard category) goes
|
|
236
|
+
* to the executor's own stderr, on the host that already has it.
|
|
237
|
+
*
|
|
238
|
+
* It does NOT say "retry" anywhere, deliberately. The condition recurs
|
|
239
|
+
* identically until an operator changes the host's worktree tooling.
|
|
240
|
+
*/
|
|
241
|
+
export const WORKER_ENV_FILE_PRESENT_MESSAGE = "an environment file was present in this job's worktree at spawn and could not be confirmed " +
|
|
242
|
+
"removed, so no worker was started. A worker that reads one resolves real credentials and a " +
|
|
243
|
+
"real database. Check the copy-ignored exclusions in .config/wt.toml and any local tooling " +
|
|
244
|
+
"that writes into worktrees.";
|
|
210
245
|
// The Claude-specific matcher that used to live here moved into the Claude
|
|
211
246
|
// executor adapter as `classifyClaudeAuthFailure` (BAPI-781). Recognizing one
|
|
212
247
|
// CLI's not-logged-in output is precisely a per-agent capability, and leaving it
|
|
@@ -217,8 +252,14 @@ export const WORKTREE_BUSY_MESSAGE = "another executor job already holds this ti
|
|
|
217
252
|
// classification onto a stable executor failure remains executor-owned, so an
|
|
218
253
|
// adapter cannot invent its own error kinds or its own operator-facing
|
|
219
254
|
// remediation text.
|
|
220
|
-
/**
|
|
221
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Bound a failure message so no unbounded/secret-bearing text is posted.
|
|
257
|
+
*
|
|
258
|
+
* EXPORTED (BAPI-1019) so a fixed-message contract test can assert against the
|
|
259
|
+
* same number {@link ExecutorNamedError} truncates at, rather than restating
|
|
260
|
+
* `300` and silently going vacuous the day the bound changes.
|
|
261
|
+
*/
|
|
262
|
+
export const ERROR_MESSAGE_MAX_CHARS = 300;
|
|
222
263
|
/**
|
|
223
264
|
* An `Error` subclass carrying a stable `errorKind` plus a bounded
|
|
224
265
|
* `errorMessage`. Named executor failures default to the `crashed` wire
|