@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
|
@@ -23,11 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
|
|
25
25
|
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
26
|
-
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
|
|
26
|
+
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
27
27
|
import { secretFreeErrorMessage } from "./job-errors.js";
|
|
28
28
|
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
29
|
-
/** `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950. */
|
|
30
|
-
const EPIC_BRANCH_PREFIX = "epic/";
|
|
31
29
|
function positiveIntOrNull(value) {
|
|
32
30
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
33
31
|
}
|
|
@@ -42,8 +40,21 @@ function asString(value) {
|
|
|
42
40
|
* Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
|
|
43
41
|
* over any payload copy, validates `pr_number` as a positive integer, and fails
|
|
44
42
|
* (before any subprocess) when the PR number or expected head SHA is absent.
|
|
43
|
+
*
|
|
44
|
+
* BAPI-1021 (AC-11): also resolves the two branch-ref expectations the local
|
|
45
|
+
* merge executor's ref-verification guard needs, and fails closed — before any
|
|
46
|
+
* `makeLocalMergeExecutor` construction or `runCommand` seam is reached — when
|
|
47
|
+
* either cannot be resolved:
|
|
48
|
+
*
|
|
49
|
+
* - `expectedBaseBranch` via {@link resolveExecutorJobBaseBranch}, the SAME
|
|
50
|
+
* resolver every other PR-producing job type uses, with `repositoryBaseBranch`
|
|
51
|
+
* as its documented legacy fallback for a payload omitting `base_branch`.
|
|
52
|
+
* - `expectedHeadBranch` EXCLUSIVELY from a trimmed, nonblank
|
|
53
|
+
* `job.expected_branch` — never from payload data, which is untrusted and
|
|
54
|
+
* job-type-generic. There is no fallback: a merge job with no known expected
|
|
55
|
+
* head branch cannot be ref-verified.
|
|
45
56
|
*/
|
|
46
|
-
export function readMergeJobPayloadFields(job) {
|
|
57
|
+
export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
|
|
47
58
|
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
|
|
48
59
|
const prNumber = positiveIntOrNull(payload.pr_number);
|
|
49
60
|
if (prNumber === null) {
|
|
@@ -53,6 +64,15 @@ export function readMergeJobPayloadFields(job) {
|
|
|
53
64
|
if (!expectedHeadSha) {
|
|
54
65
|
return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
|
|
55
66
|
}
|
|
67
|
+
const baseResolution = resolveExecutorJobBaseBranch(job, repositoryBaseBranch);
|
|
68
|
+
if (!baseResolution.ok) {
|
|
69
|
+
return { ok: false, error: baseResolution.error };
|
|
70
|
+
}
|
|
71
|
+
const expectedBaseBranch = baseResolution.baseBranch;
|
|
72
|
+
const expectedHeadBranch = asString(job.expected_branch ?? undefined);
|
|
73
|
+
if (!expectedHeadBranch) {
|
|
74
|
+
return { ok: false, error: "merge job requires a nonblank expected_branch" };
|
|
75
|
+
}
|
|
56
76
|
const method = resolveLocalMergeMethod(payload.method);
|
|
57
77
|
const requiredChecks = Array.isArray(payload.required_checks)
|
|
58
78
|
? payload.required_checks.filter((c) => typeof c === "string")
|
|
@@ -62,7 +82,17 @@ export function readMergeJobPayloadFields(job) {
|
|
|
62
82
|
const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
|
|
63
83
|
return {
|
|
64
84
|
ok: true,
|
|
65
|
-
fields: {
|
|
85
|
+
fields: {
|
|
86
|
+
prNumber,
|
|
87
|
+
expectedHeadSha,
|
|
88
|
+
method,
|
|
89
|
+
requiredChecks,
|
|
90
|
+
actionKey,
|
|
91
|
+
ciWaitTimeoutMs,
|
|
92
|
+
ciWaitPollIntervalMs,
|
|
93
|
+
expectedBaseBranch,
|
|
94
|
+
expectedHeadBranch,
|
|
95
|
+
},
|
|
66
96
|
};
|
|
67
97
|
}
|
|
68
98
|
/** Construct the {@link ConductorMergeRequest} for the local merge executor. */
|
|
@@ -124,10 +154,18 @@ export function buildMergeJobResult(response, fields) {
|
|
|
124
154
|
* safe to re-fire the same merge later.
|
|
125
155
|
* - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
|
|
126
156
|
* - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
|
|
157
|
+
* - {@link MERGE_REF_MISMATCH}: BAPI-1021 — a DELIBERATE, terminal safety
|
|
158
|
+
* refusal: the PR's current base or head branch did not match the caller's
|
|
159
|
+
* expectation, so no provider merge was attempted. This is neither
|
|
160
|
+
* retryable (the mismatch will not resolve itself) nor a rebase target (it
|
|
161
|
+
* is not a conflict) — see `_merge_entry_failure_is_retryable` and
|
|
162
|
+
* `_merge_entry_failure_requires_rebase` on the backend gate-retry path,
|
|
163
|
+
* which both explicitly exclude it.
|
|
127
164
|
*/
|
|
128
165
|
export const MERGE_RETRYABLE = "MergeRetryable";
|
|
129
166
|
export const MERGE_CONFLICT = "MergeConflict";
|
|
130
167
|
export const MERGE_FAILED = "MergeFailed";
|
|
168
|
+
export const MERGE_REF_MISMATCH = "MergeRefMismatch";
|
|
131
169
|
/** Reasons whose failure is transient/infrastructure and safe to retry. */
|
|
132
170
|
const RETRYABLE_MERGE_REASONS = new Set([
|
|
133
171
|
"gh_pr_view_timeout",
|
|
@@ -151,10 +189,18 @@ const RETRYABLE_MERGE_REASONS = new Set([
|
|
|
151
189
|
* `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
|
|
152
190
|
* variants covered so a distinct reason never accidentally downgrades to
|
|
153
191
|
* {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
|
|
192
|
+
*
|
|
193
|
+
* BAPI-1021: `ref_mismatch` is classified BEFORE conflict, retryable-prefix, and
|
|
194
|
+
* generic-failure handling, and is deliberately NEVER added to
|
|
195
|
+
* {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
|
|
196
|
+
* transient or conflict-shaped failure, and must not be re-firable by any
|
|
197
|
+
* future classification change.
|
|
154
198
|
*/
|
|
155
199
|
export function classifyMergeFailureErrorKind(response) {
|
|
156
200
|
const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
|
|
157
201
|
const reason = asString(response.reason ?? undefined);
|
|
202
|
+
if (reason === "ref_mismatch")
|
|
203
|
+
return MERGE_REF_MISMATCH;
|
|
158
204
|
if (hasConflictEvent || reason === "gh_merge_conflict")
|
|
159
205
|
return MERGE_CONFLICT;
|
|
160
206
|
if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
|
|
@@ -168,13 +214,19 @@ export function classifyMergeFailureErrorKind(response) {
|
|
|
168
214
|
* `error_message` includes only the sanitized merge status and reason (never raw
|
|
169
215
|
* `gh` output). `classification` stays within the executor's vocabulary; the
|
|
170
216
|
* `error_kind` is the routing signal.
|
|
217
|
+
*
|
|
218
|
+
* BAPI-1021: a {@link MERGE_REF_MISMATCH} classifies as `classification:
|
|
219
|
+
* "clean_exit"` rather than `"crashed"` — it is a deliberate refusal the
|
|
220
|
+
* executor made correctly, not a crash. Every other merge outcome keeps its
|
|
221
|
+
* existing `"crashed"` classification.
|
|
171
222
|
*/
|
|
172
223
|
export function buildMergeJobFailure(response) {
|
|
173
224
|
const reason = asString(response.reason ?? undefined) ?? response.status;
|
|
225
|
+
const errorKind = classifyMergeFailureErrorKind(response);
|
|
174
226
|
return {
|
|
175
|
-
error_kind:
|
|
227
|
+
error_kind: errorKind,
|
|
176
228
|
error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
|
|
177
|
-
classification: "crashed",
|
|
229
|
+
classification: errorKind === MERGE_REF_MISMATCH ? "clean_exit" : "crashed",
|
|
178
230
|
};
|
|
179
231
|
}
|
|
180
232
|
/**
|
|
@@ -186,14 +238,8 @@ export function buildMergeJobFailure(response) {
|
|
|
186
238
|
* merge outcome). NEVER changes the returned result, the `error_kind`, or the
|
|
187
239
|
* `/complete` payload — only emits a sanitized advisory line.
|
|
188
240
|
*/
|
|
189
|
-
async function tryEnsurePostMergeEpicIntegrationPr(
|
|
190
|
-
if (seams.repositoryBaseBranch === undefined)
|
|
191
|
-
return;
|
|
241
|
+
async function tryEnsurePostMergeEpicIntegrationPr(seams, childBaseBranch) {
|
|
192
242
|
try {
|
|
193
|
-
const baseResolution = resolveExecutorJobBaseBranch(job, seams.repositoryBaseBranch);
|
|
194
|
-
if (!baseResolution.ok)
|
|
195
|
-
return;
|
|
196
|
-
const childBaseBranch = baseResolution.baseBranch;
|
|
197
243
|
if (!childBaseBranch.startsWith(EPIC_BRANCH_PREFIX))
|
|
198
244
|
return;
|
|
199
245
|
const epicKey = childBaseBranch.slice(EPIC_BRANCH_PREFIX.length);
|
|
@@ -220,7 +266,7 @@ async function tryEnsurePostMergeEpicIntegrationPr(job, seams) {
|
|
|
220
266
|
* bounded, secret-free executor failure.
|
|
221
267
|
*/
|
|
222
268
|
export async function runExecutorMergeJob(job, seams) {
|
|
223
|
-
const resolution = readMergeJobPayloadFields(job);
|
|
269
|
+
const resolution = readMergeJobPayloadFields(job, seams.repositoryBaseBranch);
|
|
224
270
|
if (!resolution.ok) {
|
|
225
271
|
return {
|
|
226
272
|
ok: false,
|
|
@@ -238,6 +284,11 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
238
284
|
method: fields.method,
|
|
239
285
|
ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
|
|
240
286
|
ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
|
|
287
|
+
// BAPI-1021 (AC-11): pass the already-resolved expectations through
|
|
288
|
+
// unchanged so verification and the post-merge enrichment below use one
|
|
289
|
+
// authoritative value — neither re-resolves it independently.
|
|
290
|
+
expectedBaseBranch: fields.expectedBaseBranch,
|
|
291
|
+
expectedHeadBranch: fields.expectedHeadBranch,
|
|
241
292
|
}, seams.localMergeDeps);
|
|
242
293
|
let response;
|
|
243
294
|
try {
|
|
@@ -257,7 +308,7 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
257
308
|
// Build the successful outcome COMPLETELY first — enrichment can only ever
|
|
258
309
|
// add an advisory log line, never alter what is returned.
|
|
259
310
|
const result = buildMergeJobResult(response, fields);
|
|
260
|
-
await tryEnsurePostMergeEpicIntegrationPr(
|
|
311
|
+
await tryEnsurePostMergeEpicIntegrationPr(seams, fields.expectedBaseBranch);
|
|
261
312
|
return { ok: true, result };
|
|
262
313
|
}
|
|
263
314
|
return { ok: false, failure: buildMergeJobFailure(response) };
|
|
@@ -52,6 +52,40 @@ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree,
|
|
|
52
52
|
* carries the whole Bridge-credential guarantee again, exactly as it did before
|
|
53
53
|
* BAPI-731: the shim resolves the operator's real store in its own process, and
|
|
54
54
|
* the model cannot read it.
|
|
55
|
+
*
|
|
56
|
+
* BAPI-1020 appends fifteen DESTRUCTIVE / DEPLOYING rules after the original nine.
|
|
57
|
+
* Architecture Miss 28 happened with the original set in place, because it covered
|
|
58
|
+
* credentials, force-push, and `rm -rf` at the filesystem root — and nothing else.
|
|
59
|
+
* Every external or operational system a worker could reach (`gh`, `heroku`,
|
|
60
|
+
* `gcloud`, `aws`, `kubectl`, `terraform`, `docker push`, `npm publish`, a tag
|
|
61
|
+
* push, a push to `main`) was allowed.
|
|
62
|
+
*
|
|
63
|
+
* EVERY new rule is COMMAND-scoped, and that is the Architecture Miss 23
|
|
64
|
+
* constraint restated above, not a stylistic preference: a deny glob must target
|
|
65
|
+
* the ASSET, never a directory that also holds the worker's own working state. A
|
|
66
|
+
* `Bash(<cmd>:...)` rule cannot shadow a path the CLI persists tool results into.
|
|
67
|
+
* Do not widen any of them into a directory or worktree-state path glob.
|
|
68
|
+
*
|
|
69
|
+
* Two rules a reader might expect are deliberately ABSENT because they are already
|
|
70
|
+
* covered: `Bash(git push:*--force*)` above satisfies the force-push clause, and
|
|
71
|
+
* the two `rm:*-rf` rules cover the root and home cases. The hook's
|
|
72
|
+
* worktree-relative `rm -rf` check COMPLEMENTS those two rather than replacing
|
|
73
|
+
* them — a glob cannot decide whether a relative path resolves inside the
|
|
74
|
+
* worktree, and no glob can decide whether a push destination is the job's own
|
|
75
|
+
* branch. That argument-sensitive half lives in `worker-guard-hook.ts`.
|
|
76
|
+
*
|
|
77
|
+
* `Bash(gh:*)` denies `gh` TO WORKERS ONLY, and the distinction is load-bearing —
|
|
78
|
+
* do not "fix" this denial when a conductor merge appears to need `gh`. Conductor
|
|
79
|
+
* merges run in the EXECUTOR PROCESS: `merge-job.ts` spawns no worker, and
|
|
80
|
+
* `conductor/local-merge.ts` runs `gh pr view` / `gh pr merge` in the executor's
|
|
81
|
+
* own process, which reads no worktree `settings.local.json`. The merge path is
|
|
82
|
+
* therefore unaffected by this rule. The mandatory worker paths already avoid `gh`
|
|
83
|
+
* too (push via git's credential helper; PRs via the `create_pull_request` MCP
|
|
84
|
+
* tool — see the note in `env.ts`). `pr-base-contract.ts`'s
|
|
85
|
+
* `buildPrBaseContractLaunchInstruction()` does instruct `gh pr create`, but its
|
|
86
|
+
* only production caller is the INTERACTIVE `start-tickets` path, which is
|
|
87
|
+
* provisioned through `start-tickets-conductor.ts` and never receives these rules.
|
|
88
|
+
* If that ever changes, the instruction — not this rule — is what moves.
|
|
55
89
|
*/
|
|
56
90
|
export function executorDenyRules(inputs) {
|
|
57
91
|
return [
|
|
@@ -64,8 +98,46 @@ export function executorDenyRules(inputs) {
|
|
|
64
98
|
"Bash(git push:*--force*)",
|
|
65
99
|
"Bash(rm:*-rf /*)",
|
|
66
100
|
"Bash(rm:*-rf ~*)",
|
|
101
|
+
// BAPI-1020 — destructive / deploying commands, in the ticket's stated order.
|
|
102
|
+
"Bash(gh:*)",
|
|
103
|
+
"Bash(git push:*--tags*)",
|
|
104
|
+
"Bash(heroku:*)",
|
|
105
|
+
"Bash(git push:*refs/tags/*)",
|
|
106
|
+
"Bash(gcloud:*)",
|
|
107
|
+
"Bash(git push:*--delete*)",
|
|
108
|
+
"Bash(aws:*)",
|
|
109
|
+
"Bash(git push:* :*)",
|
|
110
|
+
"Bash(kubectl:*)",
|
|
111
|
+
"Bash(git push:* main*)",
|
|
112
|
+
"Bash(terraform:*)",
|
|
113
|
+
"Bash(git push:* epic/*)",
|
|
114
|
+
"Bash(docker:push*)",
|
|
115
|
+
"Bash(git push:*mcp-server/v*)",
|
|
116
|
+
"Bash(npm:publish*)",
|
|
67
117
|
];
|
|
68
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Branch names that a BRANCH-SHAPED deny glob would match (BAPI-1020).
|
|
121
|
+
*
|
|
122
|
+
* Two of the fifteen new rules — `Bash(git push:* main*)` and
|
|
123
|
+
* `Bash(git push:* epic/*)` — deny by branch shape rather than by command. If a
|
|
124
|
+
* worker's OWN branch ever matched one, the static deny would block the push the
|
|
125
|
+
* guard is required to allow, and AC-10 ("it still pushes its own branch") would
|
|
126
|
+
* break at push time with an error naming a rule nobody connected to the branch
|
|
127
|
+
* name. The hook cannot rescue it: a PreToolUse allow does not override a
|
|
128
|
+
* settings-level deny.
|
|
129
|
+
*
|
|
130
|
+
* Today the collision is unreachable — worker branches come from `wt.branch`, the
|
|
131
|
+
* Worktrunk `feature/<KEY>` branch, and `epic/<EPIC>` appears only as a merge base
|
|
132
|
+
* — but nothing wrote that invariant down. This does, and it fails LOUDLY at
|
|
133
|
+
* provisioning time rather than mysteriously at push time.
|
|
134
|
+
*/
|
|
135
|
+
export function workerBranchCollidesWithDenyGlob(branch) {
|
|
136
|
+
const trimmed = branch.trim();
|
|
137
|
+
if (trimmed.length === 0)
|
|
138
|
+
return false;
|
|
139
|
+
return trimmed === "main" || trimmed.startsWith("main") || trimmed.startsWith("epic/");
|
|
140
|
+
}
|
|
69
141
|
function asObject(value) {
|
|
70
142
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
71
143
|
? value
|
|
@@ -75,6 +147,12 @@ function asObject(value) {
|
|
|
75
147
|
* Merge the executor deny set into a settings object under `permissions.deny`,
|
|
76
148
|
* preserving all existing settings and appending only rules not already present
|
|
77
149
|
* (idempotent).
|
|
150
|
+
*
|
|
151
|
+
* Append-if-absent is what keeps the merge safe for a worktree provisioned by an
|
|
152
|
+
* EARLIER executor build: the nine pre-BAPI-1020 rules already sitting in the file
|
|
153
|
+
* are matched and skipped, the fifteen new ones are appended, and any unrelated
|
|
154
|
+
* operator-authored deny entry is preserved in place rather than reordered or
|
|
155
|
+
* replaced.
|
|
78
156
|
*/
|
|
79
157
|
export function mergeExecutorDenySettings(existing, inputs) {
|
|
80
158
|
const permissions = { ...asObject(existing.permissions) };
|
|
@@ -272,6 +350,12 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
|
272
350
|
* and shield that file from worker commits via `.git/info/exclude` (BAPI-740/F2).
|
|
273
351
|
* Fails open: a malformed existing file is preserved (not clobbered) and I/O
|
|
274
352
|
* errors return a warning — the caller still spawns the worker.
|
|
353
|
+
*
|
|
354
|
+
* BAPI-1020: this call now provisions TWO things into the same file — the deny
|
|
355
|
+
* rules and the always-on deterministic worker-guard PreToolUse hook. They are one
|
|
356
|
+
* write because they are one worktree-provisioning boundary, not because they are
|
|
357
|
+
* one mechanism: the rules deny command FAMILIES, the guard denies destructive
|
|
358
|
+
* ARGUMENTS, and neither can express the other's half.
|
|
275
359
|
*/
|
|
276
360
|
export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
277
361
|
const inputs = {
|
|
@@ -281,6 +365,12 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
281
365
|
};
|
|
282
366
|
const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
|
|
283
367
|
let merged = mergeExecutorDenySettings(existing, inputs);
|
|
368
|
+
// BAPI-1020 — the worker guard is registered UNCONDITIONALLY, before and
|
|
369
|
+
// independently of the conditional fallback below. PreToolUse hooks fire in
|
|
370
|
+
// EVERY permission mode, bypass included, which is what makes the guard hold
|
|
371
|
+
// even under the `skip_permissions` revert value. The merge is idempotent
|
|
372
|
+
// per command, so a re-provisioned worktree does not accumulate duplicates.
|
|
373
|
+
merged = mergeClaudeSettingsWithCommandHook(merged, options.workerGuardHookCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
|
|
284
374
|
if (options.preToolUseFallbackCommand) {
|
|
285
375
|
merged = mergeClaudeSettingsWithCommandHook(merged, options.preToolUseFallbackCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
|
|
286
376
|
}
|
|
@@ -290,6 +380,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
290
380
|
// may already exist from an earlier provisioning, so it runs even when the
|
|
291
381
|
// merge-write was refused or failed.
|
|
292
382
|
const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
|
|
383
|
+
// BAPI-1020 — the branch-shaped-glob collision check. Advisory rather than
|
|
384
|
+
// blocking, matching this function's established fail-open posture: the worker
|
|
385
|
+
// can still do most of its job, and killing the spawn would trade a push failure
|
|
386
|
+
// for a total one. It is loud where it matters — the warning names the condition
|
|
387
|
+
// at PROVISIONING time, so an operator reads it before the push fails, instead of
|
|
388
|
+
// decoding a deny rule from a push error twenty minutes later.
|
|
389
|
+
const collidingBranch = typeof options.workerBranch === "string" &&
|
|
390
|
+
workerBranchCollidesWithDenyGlob(options.workerBranch);
|
|
293
391
|
// `blocking` and `advisory` are kept apart on purpose. A blocking warning
|
|
294
392
|
// flips `ok` (the established contract, which existing callers and tests
|
|
295
393
|
// read); an advisory one rides along on `ok: true` so a degraded-but-
|
|
@@ -303,6 +401,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
303
401
|
blocking.push(exclude.warning);
|
|
304
402
|
else if (exclude.warning)
|
|
305
403
|
advisory.push(exclude.warning);
|
|
404
|
+
if (collidingBranch) {
|
|
405
|
+
// The branch NAME is not echoed: this text travels into worker logs, and the
|
|
406
|
+
// condition ("matches a branch-shaped deny glob") is the whole diagnostic.
|
|
407
|
+
advisory.push("the job branch matches a branch-shaped executor deny glob (git push to main/ or " +
|
|
408
|
+
"epic/); the worker's own push would be denied by settings permissions.deny, " +
|
|
409
|
+
"which the PreToolUse guard cannot override — re-cut the branch before relying " +
|
|
410
|
+
"on this worktree");
|
|
411
|
+
}
|
|
306
412
|
if (blocking.length > 0) {
|
|
307
413
|
return { ok: false, warning: [...blocking, ...advisory].join("; ") };
|
|
308
414
|
}
|
|
@@ -46,9 +46,25 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
return parsed;
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* True only for a result safe to reuse: genuinely enforced by a real layer, for
|
|
51
|
+
* EVERY posture that was probed.
|
|
52
|
+
*
|
|
53
|
+
* BAPI-1020 added the per-posture clause. The combined result already refuses to
|
|
54
|
+
* report `enforced: true` unless every required posture passed, but caching is the
|
|
55
|
+
* one place where being wrong is STICKY — a cached result is reused for the rest
|
|
56
|
+
* of the executor's life — so the check is made against the per-posture record
|
|
57
|
+
* directly rather than trusted to the conjunction upstream. A spawn failure, a
|
|
58
|
+
* hang, a control refusal, and a degraded guess all report `layer: "none"` for the
|
|
59
|
+
* posture they happened under, and none of them is cached.
|
|
60
|
+
*/
|
|
50
61
|
function isCacheableDenyResult(result) {
|
|
51
|
-
|
|
62
|
+
if (result.enforced !== true || result.layer === "none")
|
|
63
|
+
return false;
|
|
64
|
+
const postures = result.postures;
|
|
65
|
+
if (postures === undefined)
|
|
66
|
+
return true;
|
|
67
|
+
return postures.every((p) => p.enforced === true && p.layer !== "none");
|
|
52
68
|
}
|
|
53
69
|
/** Create a fresh, empty executor-scoped deny-probe cache. */
|
|
54
70
|
export function createDenyProbeCache() {
|
|
@@ -343,18 +359,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
343
359
|
};
|
|
344
360
|
}
|
|
345
361
|
/**
|
|
346
|
-
* Build the HTTP claim manifest — the eight original T2-accepted keys, plus
|
|
347
|
-
* optional
|
|
348
|
-
*
|
|
349
|
-
* (
|
|
350
|
-
*
|
|
362
|
+
* Build the HTTP claim manifest — the eight original T2-accepted keys, plus at
|
|
363
|
+
* most ONE optional scope-intent key: `epic_run_ids` (BAPI-794) when the
|
|
364
|
+
* executor was configured with `--epic-run-id`, or `repo_wide: true`
|
|
365
|
+
* (BAPI-1026) when it was configured with `--repo-wide`. `free_slots` is
|
|
366
|
+
* supplied by the runner (dynamic). The internal-only `gh` auth capability is
|
|
367
|
+
* intentionally NOT included (not accepted by the T2 request schema).
|
|
368
|
+
*
|
|
369
|
+
* The two scope keys are mutually exclusive by construction: `parseExecutorArgs`
|
|
370
|
+
* refuses to start an executor that supplied both, and the server rejects the
|
|
371
|
+
* combination with a 422 anyway. Exactly one is always present, because since
|
|
372
|
+
* BAPI-1026 the CLI also refuses to start with neither.
|
|
351
373
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* `undefined`-valued.
|
|
374
|
+
* Both are added via a conditional spread, not a bare property assignment:
|
|
375
|
+
* `{ epic_run_ids: options.epicRunIds }` would still create the key (with value
|
|
376
|
+
* `undefined`) when unscoped, which `Object.keys()` — unlike `JSON.stringify` —
|
|
377
|
+
* does NOT drop. The eight-key compatibility contract is pinned on
|
|
378
|
+
* `Object.keys()`, so an unused key must be truly absent, not merely
|
|
379
|
+
* `undefined`-valued. For the same reason `repo_wide` is never serialized as
|
|
380
|
+
* `false`: `false` is the server's default and means "not an opt-out", so
|
|
381
|
+
* sending it would add a key that carries no information.
|
|
358
382
|
*/
|
|
359
383
|
export function buildClaimManifest(report, options, freeSlots) {
|
|
360
384
|
return {
|
|
@@ -367,5 +391,6 @@ export function buildClaimManifest(report, options, freeSlots) {
|
|
|
367
391
|
free_slots: freeSlots,
|
|
368
392
|
disk_free_gb: report.diskFreeGb ?? 0,
|
|
369
393
|
...(options.epicRunIds !== undefined ? { epic_run_ids: options.epicRunIds } : {}),
|
|
394
|
+
...(options.repoWide === true ? { repo_wide: true } : {}),
|
|
370
395
|
};
|
|
371
396
|
}
|
|
@@ -51,7 +51,8 @@ export function resolveResumeExpectedBranch(job, fields) {
|
|
|
51
51
|
* Resolve the server-provided prompt template, failing loud with a contract
|
|
52
52
|
* error when it is missing/empty. Validated BEFORE any worktree resolution or
|
|
53
53
|
* WIP-checkpoint work so a malformed `resume` payload never spawns a real
|
|
54
|
-
* `claude -p ""
|
|
54
|
+
* `claude -p ""` worker — under whatever posture the run policy resolved, which
|
|
55
|
+
* since BAPI-1020 defaults to `--permission-mode auto` — that would burn the full
|
|
55
56
|
* job timeout before failing generically (mirrors the `implement`/`spec_review`
|
|
56
57
|
* `ContractError.Prompt` fail-fast in `resolveExecutorPrompt`).
|
|
57
58
|
*/
|
package/build/executor/runner.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
|
|
10
10
|
import { runClaimedJob } from "./job-runner.js";
|
|
11
11
|
import { appendToActiveJobLogs } from "./job-log-registry.js";
|
|
12
|
-
import { createLiveWorkerRegistry } from "./live-worker-registry.js";
|
|
12
|
+
import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
|
|
13
13
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
14
|
+
import { validateBranchName } from "../base-ref.js";
|
|
15
|
+
import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
14
16
|
/**
|
|
15
17
|
* Signals that request a graceful executor shutdown (BAPI-828).
|
|
16
18
|
*
|
|
@@ -133,6 +135,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
133
135
|
// Read-only diagnostics, exactly like `lastClaimedEpicRunId` above: it never
|
|
134
136
|
// feeds claim eligibility, cadence, retries, dispatch, or exit.
|
|
135
137
|
let lastDispatcherAvailability = null;
|
|
138
|
+
// BAPI-1010 — one-shot executor wind-down observer state. Declared here,
|
|
139
|
+
// OUTSIDE both the claim loop and the poll loop, so a readiness attempt
|
|
140
|
+
// fires at most once for the life of this `runExecutor` invocation rather
|
|
141
|
+
// than once per poll cycle.
|
|
142
|
+
let markReadyAttempted = false;
|
|
143
|
+
// Suppresses the missing-read-capability advisory to one line per
|
|
144
|
+
// invocation, mirroring the dispatcher-liveness suppression above.
|
|
145
|
+
let missingReadCapabilityWarned = false;
|
|
136
146
|
/**
|
|
137
147
|
* Announce a dispatcher availability CHANGE, and only a change.
|
|
138
148
|
*
|
|
@@ -164,6 +174,109 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
164
174
|
deps.errorLog(formatDispatcherRecoveredDiagnostic());
|
|
165
175
|
}
|
|
166
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the single epic run this executor process is unambiguously scoped
|
|
179
|
+
* to observe for wind-down, or `null` when the scope is absent or ambiguous.
|
|
180
|
+
*
|
|
181
|
+
* BAPI-1010: Slice 1 (`ExecutorOptions.epicRunIds`, BAPI-794) does not
|
|
182
|
+
* guarantee a dedicated executor receives exactly one run id — an operator
|
|
183
|
+
* may repeat `--epic-run-id` for several runs. Observing an arbitrary entry
|
|
184
|
+
* from a multi-run scope would silently pick the wrong epic, so the
|
|
185
|
+
* mutation stays disabled for both an unscoped executor (`epicRunIds`
|
|
186
|
+
* absent) and an ambiguous one (more than one id).
|
|
187
|
+
*
|
|
188
|
+
* BAPI-1026 (R54) DELIBERATELY LEAVES THIS CONDITION UNCHANGED, and that is
|
|
189
|
+
* the whole decision, not an omission.
|
|
190
|
+
*
|
|
191
|
+
* Making run scoping the default raised a real hazard: if the server could
|
|
192
|
+
* hand an executor a single-run scope it had not asked for, this gate would
|
|
193
|
+
* start authorizing a GitHub write — the one-shot epic→base integration-PR
|
|
194
|
+
* mutation — for processes that never had it, invisibly, as a side effect of
|
|
195
|
+
* a claim-scoping change. The decision is to keep the wind-down gated on
|
|
196
|
+
* EXPLICIT OPERATOR SCOPE only.
|
|
197
|
+
*
|
|
198
|
+
* No provenance flag is needed to express that, because R54 removed the only
|
|
199
|
+
* way a non-operator scope could arise: there is no server-derived scope. The
|
|
200
|
+
* server owns no executor-to-run binding to derive one from, so the accepted
|
|
201
|
+
* design makes the scope client-supplied and the CLI refuse to start without
|
|
202
|
+
* an explicit `--epic-run-id` or `--repo-wide`. Every `epicRunIds` value that
|
|
203
|
+
* can reach this line therefore came from an operator's `--epic-run-id`
|
|
204
|
+
* flags, exactly as it did before R54, and `--repo-wide` leaves `epicRunIds`
|
|
205
|
+
* absent so it cannot enable the mutation either.
|
|
206
|
+
*
|
|
207
|
+
* If a future slice ever introduces a server-derived scope, this gate must
|
|
208
|
+
* grow an explicit provenance field at the same time — cardinality would stop
|
|
209
|
+
* being evidence of operator intent the moment that happens.
|
|
210
|
+
*/
|
|
211
|
+
const observedEpicRunId = options.epicRunIds !== undefined && options.epicRunIds.length === 1
|
|
212
|
+
? options.epicRunIds[0]
|
|
213
|
+
: null;
|
|
214
|
+
/**
|
|
215
|
+
* One-shot epic-run completion observer (BAPI-1010).
|
|
216
|
+
*
|
|
217
|
+
* Called after every successful claim response (`claimed` or `none`), next
|
|
218
|
+
* to `observeDispatcherLiveness`. The FIRST time the scoped run's state
|
|
219
|
+
* reads back `status: "done"` on a valid `epic/`-prefixed feature branch,
|
|
220
|
+
* this requests the epic's existing draft integration pull request be
|
|
221
|
+
* marked ready for review — it never creates a merge and never merges
|
|
222
|
+
* anything; only a human merges an epic integration branch (see
|
|
223
|
+
* `epic-integration-pr.ts`). Every failure here — a missing read
|
|
224
|
+
* capability, a sanitized transport/validation failure, an invalid branch,
|
|
225
|
+
* or an unexpected throw — is ADVISORY: it is logged (credential-free,
|
|
226
|
+
* bounded) and never affects claiming, running jobs, or the runner's exit.
|
|
227
|
+
*/
|
|
228
|
+
const observeEpicRunCompletion = async () => {
|
|
229
|
+
if (markReadyAttempted || observedEpicRunId === null)
|
|
230
|
+
return;
|
|
231
|
+
const readState = httpClient.readEpicRunState;
|
|
232
|
+
if (typeof readState !== "function") {
|
|
233
|
+
if (!missingReadCapabilityWarned) {
|
|
234
|
+
missingReadCapabilityWarned = true;
|
|
235
|
+
deps.errorLog("executor: HTTP client lacks readEpicRunState; skipping epic wind-down observation.");
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
|
|
241
|
+
if (!result.ok) {
|
|
242
|
+
deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (result.state.status !== "done")
|
|
246
|
+
return;
|
|
247
|
+
const featureBranch = result.state.featureBranch;
|
|
248
|
+
if (typeof featureBranch !== "string" || !featureBranch.startsWith(EPIC_BRANCH_PREFIX)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const epicKey = featureBranch.slice(EPIC_BRANCH_PREFIX.length);
|
|
252
|
+
if (epicKey.length === 0)
|
|
253
|
+
return;
|
|
254
|
+
if (validateBranchName(featureBranch) !== null)
|
|
255
|
+
return;
|
|
256
|
+
if (validateBranchName(options.baseBranch) !== null)
|
|
257
|
+
return;
|
|
258
|
+
// Marked BEFORE constructing/awaiting the PR operation — a slow or
|
|
259
|
+
// failing `gh` call must never let a later poll cycle start a second
|
|
260
|
+
// attempt while this one is still in flight.
|
|
261
|
+
markReadyAttempted = true;
|
|
262
|
+
const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
|
|
263
|
+
const outcome = await ensureEpicIntegrationPullRequest({
|
|
264
|
+
epicKey,
|
|
265
|
+
epicBranch: featureBranch,
|
|
266
|
+
baseBranch: options.baseBranch,
|
|
267
|
+
command: "executor wind-down",
|
|
268
|
+
requestReady: true,
|
|
269
|
+
gh,
|
|
270
|
+
cwd: deps.cwd,
|
|
271
|
+
});
|
|
272
|
+
deps.errorLog(`executor: epic wind-down integration PR: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Advisory-only: never allowed to affect claiming, running jobs, or the
|
|
276
|
+
// runner's exit code.
|
|
277
|
+
deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
|
|
278
|
+
}
|
|
279
|
+
};
|
|
167
280
|
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
168
281
|
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
169
282
|
// the loop would be discarded every cycle and re-probe exactly as before; a
|
|
@@ -182,7 +295,6 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
182
295
|
// not module-global. Every dispatched job receives it, so a worker spawned by
|
|
183
296
|
// any of them is reachable from the signal handlers installed below.
|
|
184
297
|
const liveWorkers = createLiveWorkerRegistry();
|
|
185
|
-
const control = { liveWorkers };
|
|
186
298
|
let shutdownRequested = false;
|
|
187
299
|
/**
|
|
188
300
|
* Wake functions for whichever poll sleep is currently in flight. A list whose
|
|
@@ -210,6 +322,35 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
210
322
|
for (const wake of shutdownWaiters.splice(0, shutdownWaiters.length))
|
|
211
323
|
wake();
|
|
212
324
|
};
|
|
325
|
+
/**
|
|
326
|
+
* Process-level auth-fatal handler (BAPI-1021, AC-5). A dispatched job
|
|
327
|
+
* (claim, heartbeat, complete, or fail) calls this via
|
|
328
|
+
* `control.reportAuthFatal` on its FIRST 401/403 — a credential that will
|
|
329
|
+
* never become valid again by waiting.
|
|
330
|
+
*
|
|
331
|
+
* Deliberately just `beginShutdown()` underneath: an auth halt is the same
|
|
332
|
+
* kind of stop as a `SIGTERM`/`SIGINT` — terminate live workers gracefully
|
|
333
|
+
* and stop claiming new work — reusing its idempotent flag and drain
|
|
334
|
+
* behavior rather than inventing a parallel shutdown path. That reuse is
|
|
335
|
+
* also what keeps this handler from ever running a worktree
|
|
336
|
+
* reset/recreation or a conservative GC sweep: neither the existing
|
|
337
|
+
* shutdown path nor this one performs either.
|
|
338
|
+
*
|
|
339
|
+
* The ONE bounded, secret-safe log line is guarded SEPARATELY from
|
|
340
|
+
* `beginShutdown`'s own idempotency: two jobs racing an auth failure nearly
|
|
341
|
+
* concurrently must produce exactly one `executor_auth_failure` line, even
|
|
342
|
+
* though `beginShutdown()` itself is safe to call from both.
|
|
343
|
+
*/
|
|
344
|
+
let authFailureLogged = false;
|
|
345
|
+
const reportAuthFatal = (notice) => {
|
|
346
|
+
if (!authFailureLogged) {
|
|
347
|
+
authFailureLogged = true;
|
|
348
|
+
deps.errorLog(`executor_auth_failure: category=${notice.category} status=${notice.httpStatus} ` +
|
|
349
|
+
`job_id=${notice.jobId ?? "n/a"} repo=${notice.repoName}`);
|
|
350
|
+
}
|
|
351
|
+
beginShutdown();
|
|
352
|
+
};
|
|
353
|
+
const control = { liveWorkers, reportAuthFatal };
|
|
213
354
|
/**
|
|
214
355
|
* Sleep one poll interval, or return early once shutdown is requested.
|
|
215
356
|
*
|
|
@@ -453,15 +594,26 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
453
594
|
// not evidence about the dispatcher, so it is deliberately not observed.
|
|
454
595
|
if (result.kind === "claimed" || result.kind === "none") {
|
|
455
596
|
observeDispatcherLiveness(result.reconcilerLiveness);
|
|
597
|
+
await observeEpicRunCompletion();
|
|
456
598
|
}
|
|
457
599
|
if (result.kind === "claimed") {
|
|
458
600
|
const currentEpicRunId = result.job.epic_run_id ?? null;
|
|
459
601
|
if (lastClaimedEpicRunId !== null &&
|
|
460
602
|
currentEpicRunId !== null &&
|
|
461
603
|
currentEpicRunId !== lastClaimedEpicRunId) {
|
|
604
|
+
// BAPI-1026 (R54) — RETARGETED, not removed. `scoped=` used to
|
|
605
|
+
// report `epicRunIds !== undefined`, which this ticket could have
|
|
606
|
+
// made universally true and so turned the warning it guards into
|
|
607
|
+
// dead code. It reports the claim-scope MODE instead, which stays
|
|
608
|
+
// genuinely two-valued: a `--repo-wide` executor legitimately
|
|
609
|
+
// claims across runs (the warning is expected there), while a
|
|
610
|
+
// scoped executor seeing a cross-run claim is a real anomaly worth
|
|
611
|
+
// the line. Bounded context only — a mode label and the run IDs
|
|
612
|
+
// already present in this pre-existing message.
|
|
462
613
|
deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
|
|
463
614
|
`(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
|
|
464
|
-
`current_epic_run_id=${currentEpicRunId}
|
|
615
|
+
`current_epic_run_id=${currentEpicRunId} ` +
|
|
616
|
+
`claim_scope_mode=${options.epicRunIds !== undefined ? "scoped" : "repo_wide"})`);
|
|
465
617
|
}
|
|
466
618
|
if (currentEpicRunId !== null)
|
|
467
619
|
lastClaimedEpicRunId = currentEpicRunId;
|
|
@@ -478,6 +630,18 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
478
630
|
else {
|
|
479
631
|
deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
|
|
480
632
|
claiming = false;
|
|
633
|
+
// BAPI-1021 (AC-5): a categorized 401/403 fatal claim halts the
|
|
634
|
+
// RUNNER through the same handler a heartbeat/complete/fail auth
|
|
635
|
+
// failure reaches — not merely this claim iteration. No job was
|
|
636
|
+
// ever claimed, so the notice names the manifest's repository and
|
|
637
|
+
// omits `jobId`.
|
|
638
|
+
if (result.kind === "fatal" && result.authCategory === "auth_fatal") {
|
|
639
|
+
control.reportAuthFatal?.({
|
|
640
|
+
repoName: manifest.repo_name,
|
|
641
|
+
category: "claim",
|
|
642
|
+
httpStatus: result.authHttpStatus ?? 401,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
481
645
|
}
|
|
482
646
|
}
|
|
483
647
|
// Conservative worktree GC runs once per cycle, AFTER the claim attempt and
|
|
@@ -485,7 +649,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
485
649
|
// both the startup cycle and between-poll-cycle cadence without ever
|
|
486
650
|
// sweeping while a job is running. Kept off the pre-claim path so it never
|
|
487
651
|
// delays claiming a ready job.
|
|
488
|
-
|
|
652
|
+
//
|
|
653
|
+
// BAPI-1021 (AC-5): also skipped once shutdown was requested DURING this
|
|
654
|
+
// cycle's claim loop (a claim-time auth-fatal is the case that matters —
|
|
655
|
+
// it can flip `shutdownRequested` while `active.size` is still 0). No
|
|
656
|
+
// worktree-mutating sweep may run once the auth-fatal flag is set.
|
|
657
|
+
if (!shutdownRequested) {
|
|
658
|
+
await maybeSweepWorktrees();
|
|
659
|
+
}
|
|
489
660
|
}
|
|
490
661
|
if (options.once || shutdownRequested)
|
|
491
662
|
break;
|