@bridge_gpt/mcp-server 0.2.51 → 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/cut-protocol.js +17 -3
- 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 +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -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 +130 -28
- package/build/executor/merge-job.js +67 -16
- 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 +514 -121
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +5 -3
- package/build/plan-epic-conductor-eligibility.js +37 -7
- 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 +560 -139
- package/build/sfcc/log-query.js +2 -1
- 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 +3 -2
|
@@ -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
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
15
15
|
* read-only viewer tabs wired around every spawned worker.
|
|
16
16
|
*/
|
|
17
|
-
import { rm } from "node:fs/promises";
|
|
17
|
+
import { rm, readdir, lstat, unlink } from "node:fs/promises";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
-
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, } from "./job-errors.js";
|
|
20
|
+
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
|
|
21
21
|
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
22
|
import { createObservationState } from "./observation.js";
|
|
23
23
|
import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
@@ -27,9 +27,13 @@ import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
|
27
27
|
import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
|
|
28
28
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
29
29
|
import { renderPromptSpecPrompt } from "./prompt-spec.js";
|
|
30
|
+
import { defaultResolveBinPath } from "../start-tickets-conductor.js";
|
|
31
|
+
/** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
|
|
32
|
+
const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
|
|
30
33
|
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
31
34
|
import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
|
|
32
35
|
import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
36
|
+
import { stripWorkerEnvFiles } from "./env-file-guard.js";
|
|
33
37
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
34
38
|
import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
35
39
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
@@ -499,6 +503,18 @@ async function superviseProcess(params) {
|
|
|
499
503
|
isDoneRef.value = true;
|
|
500
504
|
const loopOutcome = await hb;
|
|
501
505
|
params.observation.setExitCode(result.exitCode);
|
|
506
|
+
if (loopOutcome.kind === "auth_fatal") {
|
|
507
|
+
// BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
|
|
508
|
+
// identifiers — never carried onto `result` itself, so the process's own
|
|
509
|
+
// classification/completion handling is unaffected and the job continues
|
|
510
|
+
// to drain normally.
|
|
511
|
+
params.control?.reportAuthFatal?.({
|
|
512
|
+
jobId: params.job.id,
|
|
513
|
+
repoName: params.job.repo_name,
|
|
514
|
+
category: "heartbeat",
|
|
515
|
+
httpStatus: loopOutcome.httpStatus,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
502
518
|
return loopOutcome.kind === "server_stop"
|
|
503
519
|
? { ...result, serverStopRequested: true }
|
|
504
520
|
: result;
|
|
@@ -515,7 +531,33 @@ function terminalToRunResult(terminal, successStatus) {
|
|
|
515
531
|
return { status: "abandoned", reason: "deadman" };
|
|
516
532
|
case "skipped":
|
|
517
533
|
return { status: "abandoned", reason: "already_abandoned" };
|
|
534
|
+
case "auth_fatal":
|
|
535
|
+
// BAPI-1021 (AC-5): a halted authentication/access condition, not an
|
|
536
|
+
// ordinary job failure or a lost/stale claim — `reportAuthFatal` (called
|
|
537
|
+
// by the wrapper below, before this mapping runs) is what actually
|
|
538
|
+
// notifies the runner.
|
|
539
|
+
return { status: "abandoned", reason: "auth_fatal" };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* BAPI-1021 (AC-5): send a terminal mutation via
|
|
544
|
+
* {@link sendTerminalMutationWithRetry}, and — on an `auth_fatal` outcome —
|
|
545
|
+
* notify the runner exactly once via `control.reportAuthFatal`, carrying ONLY
|
|
546
|
+
* the job ID, repository, operation category, and HTTP status. Every other
|
|
547
|
+
* outcome passes through completely unchanged.
|
|
548
|
+
*/
|
|
549
|
+
async function sendTerminalMutationWithAuthReport(params, job, control) {
|
|
550
|
+
const terminal = await sendTerminalMutationWithRetry(params);
|
|
551
|
+
if (terminal.outcome === "auth_fatal") {
|
|
552
|
+
const notice = {
|
|
553
|
+
jobId: job.id,
|
|
554
|
+
repoName: job.repo_name,
|
|
555
|
+
category: params.kind,
|
|
556
|
+
httpStatus: terminal.httpStatus,
|
|
557
|
+
};
|
|
558
|
+
control?.reportAuthFatal?.(notice);
|
|
518
559
|
}
|
|
560
|
+
return terminal;
|
|
519
561
|
}
|
|
520
562
|
/** Dispatch and run a single claimed job. */
|
|
521
563
|
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
@@ -549,10 +591,10 @@ control) {
|
|
|
549
591
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
550
592
|
// worker (TDD §5/§9).
|
|
551
593
|
if (job.job_type === "merge") {
|
|
552
|
-
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
594
|
+
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
553
595
|
}
|
|
554
596
|
if (job.job_type === "smoke") {
|
|
555
|
-
return runSmokeJob(job, httpClient, options, deps, ownership, observation);
|
|
597
|
+
return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
|
|
556
598
|
}
|
|
557
599
|
// `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
|
|
558
600
|
// `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
|
|
@@ -640,7 +682,7 @@ function createMergeFlowProcess(startMerge) {
|
|
|
640
682
|
* ignored. The local merge executor's outcome otherwise drives `/complete`
|
|
641
683
|
* (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
|
|
642
684
|
*/
|
|
643
|
-
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
685
|
+
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control) {
|
|
644
686
|
// Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
|
|
645
687
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
646
688
|
if (!timeout.ok) {
|
|
@@ -664,6 +706,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
664
706
|
proc: flow.proc,
|
|
665
707
|
timeoutSeconds,
|
|
666
708
|
collectTelemetry: async () => ({}),
|
|
709
|
+
control,
|
|
667
710
|
});
|
|
668
711
|
// Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
|
|
669
712
|
// mutation for a claim we no longer own, and never process a late outcome.
|
|
@@ -673,7 +716,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
673
716
|
// Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
|
|
674
717
|
// any later merge promise resolution (the abort has already been signalled).
|
|
675
718
|
if (procResult.classification === "timeout") {
|
|
676
|
-
const terminal = await
|
|
719
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
677
720
|
kind: "fail",
|
|
678
721
|
send: () => httpClient.fail(job, {
|
|
679
722
|
error_kind: MERGE_RETRYABLE,
|
|
@@ -685,13 +728,13 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
685
728
|
options,
|
|
686
729
|
ownership,
|
|
687
730
|
log: deps.log,
|
|
688
|
-
});
|
|
731
|
+
}, job, control);
|
|
689
732
|
return terminalToRunResult(terminal, "failed");
|
|
690
733
|
}
|
|
691
734
|
// A thrown error inside the merge flow maps to a bounded MergeFailed.
|
|
692
735
|
const flowError = flow.getError();
|
|
693
736
|
if (flowError !== undefined) {
|
|
694
|
-
const terminal = await
|
|
737
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
695
738
|
kind: "fail",
|
|
696
739
|
send: () => httpClient.fail(job, {
|
|
697
740
|
error_kind: MERGE_FAILED,
|
|
@@ -703,14 +746,14 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
703
746
|
options,
|
|
704
747
|
ownership,
|
|
705
748
|
log: deps.log,
|
|
706
|
-
});
|
|
749
|
+
}, job, control);
|
|
707
750
|
return terminalToRunResult(terminal, "failed");
|
|
708
751
|
}
|
|
709
752
|
const outcome = flow.getOutcome();
|
|
710
753
|
if (outcome === undefined) {
|
|
711
754
|
// Clean process result with no stored outcome — an internal invariant break;
|
|
712
755
|
// fail loud rather than silently reporting success.
|
|
713
|
-
const terminal = await
|
|
756
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
714
757
|
kind: "fail",
|
|
715
758
|
send: () => httpClient.fail(job, {
|
|
716
759
|
error_kind: MERGE_FAILED,
|
|
@@ -722,7 +765,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
722
765
|
options,
|
|
723
766
|
ownership,
|
|
724
767
|
log: deps.log,
|
|
725
|
-
});
|
|
768
|
+
}, job, control);
|
|
726
769
|
return terminalToRunResult(terminal, "failed");
|
|
727
770
|
}
|
|
728
771
|
if (outcome.ok) {
|
|
@@ -733,24 +776,24 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
733
776
|
result: outcome.result,
|
|
734
777
|
telemetry: observation.snapshot(),
|
|
735
778
|
};
|
|
736
|
-
const terminal = await
|
|
779
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
737
780
|
kind: "complete",
|
|
738
781
|
send: () => httpClient.complete(job, completion),
|
|
739
782
|
deps,
|
|
740
783
|
options,
|
|
741
784
|
ownership,
|
|
742
785
|
log: deps.log,
|
|
743
|
-
});
|
|
786
|
+
}, job, control);
|
|
744
787
|
return terminalToRunResult(terminal, "completed");
|
|
745
788
|
}
|
|
746
|
-
const terminal = await
|
|
789
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
747
790
|
kind: "fail",
|
|
748
791
|
send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
|
|
749
792
|
deps,
|
|
750
793
|
options,
|
|
751
794
|
ownership,
|
|
752
795
|
log: deps.log,
|
|
753
|
-
});
|
|
796
|
+
}, job, control);
|
|
754
797
|
return terminalToRunResult(terminal, "failed");
|
|
755
798
|
}
|
|
756
799
|
/**
|
|
@@ -767,7 +810,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
767
810
|
* job to give it something to register MCP into would be a materially larger,
|
|
768
811
|
* unratified change to that architectural boundary, not a wiring task.
|
|
769
812
|
*/
|
|
770
|
-
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
813
|
+
async function runSmokeJob(job, httpClient, options, deps, ownership, observation, control) {
|
|
771
814
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
772
815
|
if (!timeout.ok) {
|
|
773
816
|
// Consistent with runSpawnJob: a timeout-contract failure fails the job
|
|
@@ -796,6 +839,7 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
796
839
|
proc,
|
|
797
840
|
timeoutSeconds,
|
|
798
841
|
collectTelemetry: async () => ({}),
|
|
842
|
+
control,
|
|
799
843
|
});
|
|
800
844
|
if (ownership.abandoned) {
|
|
801
845
|
return { status: "abandoned", reason: ownership.abandonReason };
|
|
@@ -813,14 +857,14 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
813
857
|
result,
|
|
814
858
|
telemetry: observation.snapshot(),
|
|
815
859
|
};
|
|
816
|
-
const terminal = await
|
|
860
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
817
861
|
kind: "complete",
|
|
818
862
|
send: () => httpClient.complete(job, completion),
|
|
819
863
|
deps,
|
|
820
864
|
options,
|
|
821
865
|
ownership,
|
|
822
866
|
log: deps.log,
|
|
823
|
-
});
|
|
867
|
+
}, job, control);
|
|
824
868
|
return terminalToRunResult(terminal, "completed");
|
|
825
869
|
}
|
|
826
870
|
/**
|
|
@@ -1284,6 +1328,14 @@ async function runPreparedSpawn(params) {
|
|
|
1284
1328
|
worktreePath,
|
|
1285
1329
|
baseBranch: effectiveBaseBranch,
|
|
1286
1330
|
homedir: deps.homedir(),
|
|
1331
|
+
// BAPI-1020 — the prepared worktree's own branch and the packaged guard bin.
|
|
1332
|
+
// `branch` comes from `prepareSpawn`'s `wt.branch`, i.e. the worktree the
|
|
1333
|
+
// executor actually created, never from job payload text: the guard decides
|
|
1334
|
+
// "is this push destination mine?" by comparing against it, so a value the
|
|
1335
|
+
// payload could influence would let a job nominate its own safe branch.
|
|
1336
|
+
workerBranch: branch,
|
|
1337
|
+
workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
|
|
1338
|
+
(() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
|
|
1287
1339
|
});
|
|
1288
1340
|
// Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
|
|
1289
1341
|
// hygiene step can degrade without failing the deny layer — present-but-
|
|
@@ -1354,6 +1406,13 @@ async function runPreparedSpawn(params) {
|
|
|
1354
1406
|
writeFile: deps.writeFile,
|
|
1355
1407
|
appendFile: deps.appendFile,
|
|
1356
1408
|
platform: deps.platform,
|
|
1409
|
+
// BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
|
|
1410
|
+
// already, so this is source-compatible with every existing deps
|
|
1411
|
+
// builder — archival simply does not run when either is absent.
|
|
1412
|
+
rename: deps.rename,
|
|
1413
|
+
stat: deps.stat,
|
|
1414
|
+
now: deps.now,
|
|
1415
|
+
errorLog: deps.errorLog,
|
|
1357
1416
|
});
|
|
1358
1417
|
}
|
|
1359
1418
|
catch {
|
|
@@ -1476,6 +1535,10 @@ async function runPreparedSpawn(params) {
|
|
|
1476
1535
|
mcpConfigPath: mcpContract.configPath,
|
|
1477
1536
|
effectiveBaseBranch,
|
|
1478
1537
|
indexScope,
|
|
1538
|
+
// BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
|
|
1539
|
+
// is the deterministic guard's only source of branch identity. Same
|
|
1540
|
+
// provenance as the deny-provisioning value above: the prepared worktree.
|
|
1541
|
+
workerBranch: branch,
|
|
1479
1542
|
parentEnv: deps.env,
|
|
1480
1543
|
});
|
|
1481
1544
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -1514,6 +1577,44 @@ async function runPreparedSpawn(params) {
|
|
|
1514
1577
|
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1515
1578
|
}
|
|
1516
1579
|
const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
|
|
1580
|
+
// --- Worker env-file strip (FAIL-CLOSED, BAPI-1019/A.4) ---------------
|
|
1581
|
+
// THE LAST thing that touches the worktree before a worker exists. Placed
|
|
1582
|
+
// after the deny layer, after MCP provisioning and its verification, and after
|
|
1583
|
+
// spawn-shape/observer construction precisely so nothing that runs later can
|
|
1584
|
+
// re-introduce a file the guard just confirmed gone. Everything between this
|
|
1585
|
+
// call and `deps.spawnProcess` below is pure in-memory work.
|
|
1586
|
+
//
|
|
1587
|
+
// Fail-CLOSED, following `StaleArtifactCleanupFailed` rather than the
|
|
1588
|
+
// fail-OPEN deny-layer posture two hundred lines up. The deny layer shrinks a
|
|
1589
|
+
// safety margin when it degrades; this gate is the margin. Architecture Miss 28
|
|
1590
|
+
// was a worker that reached the operator's real database through a copied
|
|
1591
|
+
// `.env` and truncated it — so "could not confirm the worktree is clean" must
|
|
1592
|
+
// refuse, never proceed and hope.
|
|
1593
|
+
const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
|
|
1594
|
+
const stripped = await stripEnvFiles(worktreePath, {
|
|
1595
|
+
readdir: (dirPath) => readdir(dirPath),
|
|
1596
|
+
// `lstat`/`unlink` only — the guard must never resolve a symlink, because an
|
|
1597
|
+
// entry pointing at the main checkout would resolve to the operator's real
|
|
1598
|
+
// `.env` and unlinking THAT would delete it. The narrow bag is the
|
|
1599
|
+
// enforcement: no `stat`, `readlink`, or `readFile` is reachable from here.
|
|
1600
|
+
lstat: (target) => lstat(target),
|
|
1601
|
+
unlink: (target) => unlink(target),
|
|
1602
|
+
platform: deps.platform,
|
|
1603
|
+
});
|
|
1604
|
+
if (!stripped.ok) {
|
|
1605
|
+
await closeWorkerLog();
|
|
1606
|
+
await finalizeRegistry();
|
|
1607
|
+
// Exactly ONE bounded stderr line: the job id and the closed guard category.
|
|
1608
|
+
// No worktree path (it carries a username), no removed filename, no errno, no
|
|
1609
|
+
// exception text.
|
|
1610
|
+
deps.errorLog(`[executor] job ${job.id}: refusing to spawn, worker env files unconfirmed: ${stripped.category}`);
|
|
1611
|
+
await httpClient.fail(job, {
|
|
1612
|
+
error_kind: WorkerEnvFilePresent,
|
|
1613
|
+
error_message: WORKER_ENV_FILE_PRESENT_MESSAGE,
|
|
1614
|
+
classification: "crashed",
|
|
1615
|
+
});
|
|
1616
|
+
return { status: "failed", reason: "worker_env_file_present" };
|
|
1617
|
+
}
|
|
1517
1618
|
let proc;
|
|
1518
1619
|
try {
|
|
1519
1620
|
proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
|
|
@@ -1583,6 +1684,7 @@ async function runPreparedSpawn(params) {
|
|
|
1583
1684
|
// and an MCP containment refusal all converge on one `SIGTERM` and one grace
|
|
1584
1685
|
// timer for this child (BAPI-828).
|
|
1585
1686
|
terminationController,
|
|
1687
|
+
control,
|
|
1586
1688
|
});
|
|
1587
1689
|
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
1588
1690
|
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
@@ -1742,14 +1844,14 @@ async function runPreparedSpawn(params) {
|
|
|
1742
1844
|
classification: "crashed",
|
|
1743
1845
|
};
|
|
1744
1846
|
await finalizeRegistry();
|
|
1745
|
-
const terminal = await
|
|
1847
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1746
1848
|
kind: "fail",
|
|
1747
1849
|
send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
|
|
1748
1850
|
deps,
|
|
1749
1851
|
options,
|
|
1750
1852
|
ownership,
|
|
1751
1853
|
log: deps.log,
|
|
1752
|
-
});
|
|
1854
|
+
}, job, control);
|
|
1753
1855
|
return terminalToRunResult(terminal, "failed");
|
|
1754
1856
|
}
|
|
1755
1857
|
// BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
|
|
@@ -1769,14 +1871,14 @@ async function runPreparedSpawn(params) {
|
|
|
1769
1871
|
...commitResidue(git),
|
|
1770
1872
|
telemetry: observation.snapshot(),
|
|
1771
1873
|
};
|
|
1772
|
-
const terminal = await
|
|
1874
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1773
1875
|
kind: "complete",
|
|
1774
1876
|
send: () => httpClient.complete(job, completion),
|
|
1775
1877
|
deps,
|
|
1776
1878
|
options,
|
|
1777
1879
|
ownership,
|
|
1778
1880
|
log: deps.log,
|
|
1779
|
-
});
|
|
1881
|
+
}, job, control);
|
|
1780
1882
|
await finalizeRegistry();
|
|
1781
1883
|
return terminalToRunResult(terminal, "completed");
|
|
1782
1884
|
}
|
|
@@ -1806,7 +1908,7 @@ async function runPreparedSpawn(params) {
|
|
|
1806
1908
|
});
|
|
1807
1909
|
if (!finalization.ok) {
|
|
1808
1910
|
await finalizeRegistry();
|
|
1809
|
-
const terminal = await
|
|
1911
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1810
1912
|
kind: "fail",
|
|
1811
1913
|
send: () => httpClient.fail(job, {
|
|
1812
1914
|
...finalization.failure,
|
|
@@ -1817,7 +1919,7 @@ async function runPreparedSpawn(params) {
|
|
|
1817
1919
|
options,
|
|
1818
1920
|
ownership,
|
|
1819
1921
|
log: deps.log,
|
|
1820
|
-
});
|
|
1922
|
+
}, job, control);
|
|
1821
1923
|
return terminalToRunResult(terminal, "failed");
|
|
1822
1924
|
}
|
|
1823
1925
|
// BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
|
|
@@ -1840,14 +1942,14 @@ async function runPreparedSpawn(params) {
|
|
|
1840
1942
|
...commitResidue(git),
|
|
1841
1943
|
telemetry: observation.snapshot(),
|
|
1842
1944
|
};
|
|
1843
|
-
const terminal = await
|
|
1945
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1844
1946
|
kind: "complete",
|
|
1845
1947
|
send: () => httpClient.complete(job, completion),
|
|
1846
1948
|
deps,
|
|
1847
1949
|
options,
|
|
1848
1950
|
ownership,
|
|
1849
1951
|
log: deps.log,
|
|
1850
|
-
});
|
|
1952
|
+
}, job, control);
|
|
1851
1953
|
await finalizeRegistry();
|
|
1852
1954
|
return terminalToRunResult(terminal, "completed");
|
|
1853
1955
|
}
|
|
@@ -1869,7 +1971,7 @@ async function runPreparedSpawn(params) {
|
|
|
1869
1971
|
const authDetection = adapter.authFailureDetection;
|
|
1870
1972
|
const authFailure = authDetection?.supported === true &&
|
|
1871
1973
|
authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
|
|
1872
|
-
const terminal = await
|
|
1974
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1873
1975
|
kind: "fail",
|
|
1874
1976
|
send: () => httpClient.fail(job, {
|
|
1875
1977
|
error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
|
|
@@ -1884,6 +1986,6 @@ async function runPreparedSpawn(params) {
|
|
|
1884
1986
|
options,
|
|
1885
1987
|
ownership,
|
|
1886
1988
|
log: deps.log,
|
|
1887
|
-
});
|
|
1989
|
+
}, job, control);
|
|
1888
1990
|
return terminalToRunResult(terminal, "failed");
|
|
1889
1991
|
}
|