@bridge_gpt/mcp-server 0.2.36 → 0.2.38
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 +48 -8
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +70 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +185 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +57 -34
- package/build/sfcc/reads-site-preference.js +86 -33
- package/build/sfcc/reads-system-object.js +50 -38
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +71 -15
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
package/build/executor/env.js
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* FRESH object from a strict allowlist of non-secret operational keys — it
|
|
14
14
|
* never copies arbitrary `process.env`, so credentials/tokens/headers cannot
|
|
15
15
|
* leak into the spawned `claude` process. The `mcp-invoke` shim (a separate
|
|
16
|
-
* process) resolves `BAPI_API_KEY` itself at call time from
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* process) resolves `BAPI_API_KEY` itself at call time from the credential
|
|
17
|
+
* store under the worker's redirected `XDG_CONFIG_HOME` — the executor seeds a
|
|
18
|
+
* repo-scoped store into the per-job isolation directory (see
|
|
19
|
+
* `worker-config-isolation.ts`) — so this worker environment never carries it.
|
|
19
20
|
*/
|
|
20
21
|
import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
21
22
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
@@ -31,12 +32,19 @@ const ALLOWED_ENV_KEYS = [
|
|
|
31
32
|
"LANG",
|
|
32
33
|
"LC_ALL",
|
|
33
34
|
"TERM",
|
|
34
|
-
"XDG_CONFIG_HOME",
|
|
35
35
|
"NO_COLOR",
|
|
36
36
|
];
|
|
37
37
|
/** Substrings that mark a key as secret-bearing (case-insensitive). */
|
|
38
38
|
const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Explicit deny-list of known secret / MCP / conductor keys.
|
|
41
|
+
*
|
|
42
|
+
* BAPI-731 adds the two Claude configuration paths. They are denied on the
|
|
43
|
+
* INHERITED-COPY path and then set explicitly from executor-computed values
|
|
44
|
+
* below, following the `BAPI_BASE_BRANCH` precedent — an operator's
|
|
45
|
+
* `CLAUDE_CONFIG_DIR` or `XDG_CONFIG_HOME` must never be able to redirect a
|
|
46
|
+
* worker back at the operator's own configuration and undo isolation.
|
|
47
|
+
*/
|
|
40
48
|
const EXPLICIT_DENY_KEYS = [
|
|
41
49
|
"BRIDGE_MCP_PROFILE",
|
|
42
50
|
"CONDUCTOR_NODE_PATH",
|
|
@@ -45,6 +53,8 @@ const EXPLICIT_DENY_KEYS = [
|
|
|
45
53
|
"OPENAI_API_KEY",
|
|
46
54
|
"GITHUB_TOKEN",
|
|
47
55
|
"GH_TOKEN",
|
|
56
|
+
"CLAUDE_CONFIG_DIR",
|
|
57
|
+
"XDG_CONFIG_HOME",
|
|
48
58
|
];
|
|
49
59
|
/**
|
|
50
60
|
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
@@ -72,24 +82,7 @@ export function isExecutorEnvKeyAllowed(key) {
|
|
|
72
82
|
return false;
|
|
73
83
|
return true;
|
|
74
84
|
}
|
|
75
|
-
|
|
76
|
-
* Build the secret-free worker environment: a fresh object containing only the
|
|
77
|
-
* allowlisted keys that are present (and non-undefined) in `parentEnv`, plus the
|
|
78
|
-
* literal `BRIDGE_SKIP_PREPUSH=\"1\"` (BAPI-551). This is a constant, never read
|
|
79
|
-
* from `parentEnv` — the worker's advisory local pre-push suite (~70s) is
|
|
80
|
-
* redundant for a conductor worker, since its PR is CI-gated by the merge gate
|
|
81
|
-
* anyway, and the delay was observed causing a worker to background the push and
|
|
82
|
-
* exit before it landed. `BAPI_CONDUCTOR_*` identity keys remain intentionally
|
|
83
|
-
* absent from the worker env (TDD §7) — this literal does not change that.
|
|
84
|
-
*
|
|
85
|
-
* BAPI-586: when `effectiveBaseBranch` is provided (the validated run base for
|
|
86
|
-
* this job), it is injected as `BAPI_BASE_BRANCH` so the worker can run
|
|
87
|
-
* `gh pr create --base "$BAPI_BASE_BRANCH"` deterministically. It is set from the
|
|
88
|
-
* EXPLICIT job value, NOT copied from `parentEnv` (`BAPI_BASE_BRANCH` is not in
|
|
89
|
-
* the allowlist), so any inherited value cannot override the run base and no
|
|
90
|
-
* unrelated `BAPI_*` / secret value can leak in via this key.
|
|
91
|
-
*/
|
|
92
|
-
export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
|
|
85
|
+
export function buildExecutorWorkerEnv(parentEnv, options = {}) {
|
|
93
86
|
const env = {};
|
|
94
87
|
for (const key of ALLOWED_ENV_KEYS) {
|
|
95
88
|
if (!isExecutorEnvKeyAllowed(key))
|
|
@@ -100,8 +93,41 @@ export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
|
|
|
100
93
|
}
|
|
101
94
|
}
|
|
102
95
|
env.BRIDGE_SKIP_PREPUSH = "1";
|
|
103
|
-
if (typeof effectiveBaseBranch === "string" &&
|
|
104
|
-
|
|
96
|
+
if (typeof options.effectiveBaseBranch === "string" &&
|
|
97
|
+
options.effectiveBaseBranch.length > 0) {
|
|
98
|
+
env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
|
|
99
|
+
}
|
|
100
|
+
// --- Worker config isolation (BAPI-731) --------------------------------
|
|
101
|
+
// Both paths are assigned from EXPLICIT executor-computed values and are on
|
|
102
|
+
// `EXPLICIT_DENY_KEYS`, so the inherited-copy loop above can never supply them.
|
|
103
|
+
// That ordering is the isolation guarantee: an operator `CLAUDE_CONFIG_DIR` or
|
|
104
|
+
// `XDG_CONFIG_HOME` cannot redirect the worker back at the operator's own
|
|
105
|
+
// configuration (and its `projects[...].mcpServers` map).
|
|
106
|
+
if (typeof options.claudeConfigDir === "string" && options.claudeConfigDir.length > 0) {
|
|
107
|
+
env.CLAUDE_CONFIG_DIR = options.claudeConfigDir;
|
|
108
|
+
}
|
|
109
|
+
if (typeof options.xdgConfigHome === "string" && options.xdgConfigHome.length > 0) {
|
|
110
|
+
env.XDG_CONFIG_HOME = options.xdgConfigHome;
|
|
111
|
+
}
|
|
112
|
+
// The ONE deliberately-injected credential, and a genuine exception to this
|
|
113
|
+
// module's otherwise absolute secret-free rule. It is injected because
|
|
114
|
+
// isolation REMOVES the worker's previous authentication path: a worker used to
|
|
115
|
+
// authenticate as a side effect of inheriting the operator's `HOME` and
|
|
116
|
+
// `~/.claude.json`, and that inheritance is exactly the defect being closed.
|
|
117
|
+
// Per the measured inventory (`docs/claude/claude-cli-config-isolation-inventory.md`,
|
|
118
|
+
// finding 4) an operator OAuth/subscription login does NOT follow an isolated
|
|
119
|
+
// `CLAUDE_CONFIG_DIR`, while `ANTHROPIC_API_KEY` does — so this is the only
|
|
120
|
+
// supported way an isolated worker can authenticate at all.
|
|
121
|
+
//
|
|
122
|
+
// It is passed from an EXPLICIT executor-resolved value rather than copied
|
|
123
|
+
// (`ANTHROPIC_API_KEY` remains on `EXPLICIT_DENY_KEYS` and still matches the
|
|
124
|
+
// `API_KEY` secret fragment), so it travels only when the isolation strategy
|
|
125
|
+
// deliberately supplies it, never as ambient leakage. Nothing writes it to
|
|
126
|
+
// disk: the only file seeded into the isolated directory is the repo-scoped
|
|
127
|
+
// Bridge credential store (see `worker-config-isolation.ts`), which never
|
|
128
|
+
// carries the Anthropic key.
|
|
129
|
+
if (typeof options.anthropicApiKey === "string" && options.anthropicApiKey.length > 0) {
|
|
130
|
+
env.ANTHROPIC_API_KEY = options.anthropicApiKey;
|
|
105
131
|
}
|
|
106
132
|
return env;
|
|
107
133
|
}
|
|
@@ -12,40 +12,83 @@
|
|
|
12
12
|
*
|
|
13
13
|
* After abandonment the loop stops all further heartbeat / telemetry / mutation
|
|
14
14
|
* work so `runClaimedJob` never reports success after losing ownership.
|
|
15
|
+
*
|
|
16
|
+
* BAPI-731 adds two behaviors, neither of which may weaken the above:
|
|
17
|
+
*
|
|
18
|
+
* - PUSH-TRIGGERED PROMPTNESS (WS-H). The loop samples the LOCAL
|
|
19
|
+
* remote-tracking marker for the job's branch on a short sub-interval tick.
|
|
20
|
+
* When the marker first appears or changes, the worker has pushed, so the
|
|
21
|
+
* loop flushes one immediate heartbeat instead of waiting up to a full
|
|
22
|
+
* interval. This is TELEMETRY ONLY (R14): the marker is never persisted, never
|
|
23
|
+
* reported as `last_commit_sha`, and never drives an executor decision — the
|
|
24
|
+
* reconciler remains the sole consumer and the sole transition authority
|
|
25
|
+
* (`docs/claude/conductor-standing-rules.md`).
|
|
26
|
+
*
|
|
27
|
+
* - SERVER STOP (WS-B executor half). A heartbeat response carrying the
|
|
28
|
+
* server's stop request kills the owned worker like `stale_claim` does, but
|
|
29
|
+
* reports a DISTINCT outcome so the runner can send a server-cancelled
|
|
30
|
+
* terminal report rather than classifying a deliberate cancellation as a
|
|
31
|
+
* worker crash. Inert until the reconciler ticket ships the field.
|
|
32
|
+
*
|
|
33
|
+
* Every heartbeat — initial, periodic, and push-triggered — goes through the ONE
|
|
34
|
+
* `sendHeartbeat` path below, so requests can never overlap or arrive out of
|
|
35
|
+
* order.
|
|
15
36
|
*/
|
|
16
37
|
import { killOwnedProcess } from "./process.js";
|
|
38
|
+
import { normalizeHeartbeatResult } from "./types.js";
|
|
17
39
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
40
|
+
* How often the push detector samples the local remote-tracking ref. Bounded
|
|
41
|
+
* well under the heartbeat interval so a push is noticed promptly, and it is a
|
|
42
|
+
* purely local `git rev-parse` (no network), so the cost is negligible.
|
|
43
|
+
*/
|
|
44
|
+
export const PUSH_DETECT_INTERVAL_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* Run the heartbeat loop until the worker is done, ownership is abandoned, or
|
|
47
|
+
* the server requests a stop. The FIRST heartbeat fires promptly (no initial
|
|
48
|
+
* interval wait).
|
|
20
49
|
*/
|
|
21
50
|
export async function runHeartbeatLoop(params) {
|
|
22
51
|
const { job, httpClient, options, deps, ownership, proc, observation } = params;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
const pushDetectIntervalMs = params.pushDetectIntervalMs ?? PUSH_DETECT_INTERVAL_MS;
|
|
53
|
+
// Baseline for push detection. `undefined` means "not yet sampled": the FIRST
|
|
54
|
+
// successful sample only establishes the baseline and never itself triggers a
|
|
55
|
+
// flush, so a branch that was already pushed before this loop started does not
|
|
56
|
+
// manufacture a spurious immediate beat.
|
|
57
|
+
let remoteMarker;
|
|
58
|
+
let remoteBaselineEstablished = false;
|
|
59
|
+
/**
|
|
60
|
+
* The single heartbeat send path. Returns the loop outcome when the beat is
|
|
61
|
+
* terminal, or `null` to continue. Never called concurrently with itself:
|
|
62
|
+
* every caller awaits it before doing anything else.
|
|
63
|
+
*/
|
|
64
|
+
const sendHeartbeat = async () => {
|
|
31
65
|
const git = await params.collectTelemetry();
|
|
32
66
|
observation.setGitTelemetry(git);
|
|
33
67
|
const residue = observation.snapshot();
|
|
34
|
-
const
|
|
68
|
+
const result = normalizeHeartbeatResult(await httpClient.heartbeat(job, {
|
|
69
|
+
// Unconditionally the LOCAL head — never the remote marker. This is what
|
|
70
|
+
// preserves the BAPI-704 semantic that a zero-commit job reports the base
|
|
71
|
+
// SHA paired with `local_commit_count: 0`.
|
|
35
72
|
local_commit_count: git.local_commit_count,
|
|
36
73
|
last_commit_sha: git.last_commit_sha,
|
|
37
74
|
telemetry: residue,
|
|
38
|
-
});
|
|
39
|
-
|
|
75
|
+
}));
|
|
76
|
+
// Checked BEFORE the outcome switch: a stop request is authoritative even
|
|
77
|
+
// when it rides along with a non-`updated` outcome.
|
|
78
|
+
if (result.stop_requested === true) {
|
|
79
|
+
killOwnedProcess(proc, "server_stop");
|
|
80
|
+
return { kind: "server_stop" };
|
|
81
|
+
}
|
|
82
|
+
if (result.outcome === "updated") {
|
|
40
83
|
ownership.lastSuccessfulHeartbeatAt = deps.now();
|
|
41
|
-
|
|
84
|
+
return null;
|
|
42
85
|
}
|
|
43
|
-
if (outcome === "stale_claim") {
|
|
86
|
+
if (result.outcome === "stale_claim") {
|
|
44
87
|
// Definitive dead claim: kill the worker and stop touching the worktree.
|
|
45
88
|
killOwnedProcess(proc, "stale_claim");
|
|
46
89
|
ownership.abandoned = true;
|
|
47
90
|
ownership.abandonReason = "stale_claim";
|
|
48
|
-
|
|
91
|
+
return { kind: "abandoned" };
|
|
49
92
|
}
|
|
50
93
|
// retry_later / fatal_http_error: keep the worker alive within the dead-man
|
|
51
94
|
// window; SIGKILL + abandon once 2 minutes pass without a successful beat.
|
|
@@ -53,7 +96,85 @@ export async function runHeartbeatLoop(params) {
|
|
|
53
96
|
killOwnedProcess(proc, "deadman");
|
|
54
97
|
ownership.abandoned = true;
|
|
55
98
|
ownership.abandonReason = "deadman";
|
|
56
|
-
|
|
99
|
+
return { kind: "abandoned" };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Wait out one heartbeat interval in short push-detector ticks, flushing an
|
|
105
|
+
* immediate beat if the worker pushes meanwhile. Returns an outcome when a
|
|
106
|
+
* flushed beat was terminal.
|
|
107
|
+
*/
|
|
108
|
+
const waitIntervalWatchingForPush = async () => {
|
|
109
|
+
if (!params.collectRemoteMarker || pushDetectIntervalMs <= 0) {
|
|
110
|
+
await deps.sleep(options.heartbeatIntervalMs);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
let waited = 0;
|
|
114
|
+
while (waited < options.heartbeatIntervalMs) {
|
|
115
|
+
const slice = Math.min(pushDetectIntervalMs, options.heartbeatIntervalMs - waited);
|
|
116
|
+
await deps.sleep(slice);
|
|
117
|
+
waited += slice;
|
|
118
|
+
if (ownership.abandoned || params.isDone())
|
|
119
|
+
return null;
|
|
120
|
+
let sample;
|
|
121
|
+
try {
|
|
122
|
+
sample = await params.collectRemoteMarker();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Degrade-don't-throw: a failed local ref read is an observability gap,
|
|
126
|
+
// never a job failure.
|
|
127
|
+
sample = undefined;
|
|
128
|
+
}
|
|
129
|
+
if (sample === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (!remoteBaselineEstablished) {
|
|
132
|
+
remoteBaselineEstablished = true;
|
|
133
|
+
remoteMarker = sample;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (sample === remoteMarker)
|
|
137
|
+
continue;
|
|
138
|
+
// The marker moved: the worker pushed. A rewind/force-push moves it too and
|
|
139
|
+
// is treated identically — a flush trigger, never a gate or a decision.
|
|
140
|
+
remoteMarker = sample;
|
|
141
|
+
const outcome = await sendHeartbeat();
|
|
142
|
+
if (outcome)
|
|
143
|
+
return outcome;
|
|
144
|
+
if (ownership.abandoned || params.isDone())
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
};
|
|
149
|
+
let first = true;
|
|
150
|
+
while (!ownership.abandoned && !params.isDone()) {
|
|
151
|
+
if (!first) {
|
|
152
|
+
const waitOutcome = await waitIntervalWatchingForPush();
|
|
153
|
+
if (waitOutcome)
|
|
154
|
+
return waitOutcome;
|
|
155
|
+
if (ownership.abandoned || params.isDone())
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Establish the push baseline alongside the prompt first beat, so an
|
|
160
|
+
// already-pushed branch does not look like a brand-new push one tick later.
|
|
161
|
+
if (params.collectRemoteMarker) {
|
|
162
|
+
try {
|
|
163
|
+
const sample = await params.collectRemoteMarker();
|
|
164
|
+
if (sample !== undefined) {
|
|
165
|
+
remoteMarker = sample;
|
|
166
|
+
}
|
|
167
|
+
remoteBaselineEstablished = true;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
remoteBaselineEstablished = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
57
173
|
}
|
|
174
|
+
first = false;
|
|
175
|
+
const outcome = await sendHeartbeat();
|
|
176
|
+
if (outcome)
|
|
177
|
+
return outcome;
|
|
58
178
|
}
|
|
179
|
+
return ownership.abandoned ? { kind: "abandoned" } : { kind: "done" };
|
|
59
180
|
}
|
|
@@ -12,6 +12,28 @@ function isOkBody(bodyText) {
|
|
|
12
12
|
return false;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* The server-published run-level stop field on a heartbeat response (BAPI-731,
|
|
17
|
+
* WS-B). Owned by the reconciler safety-plane ticket; until it ships the field is
|
|
18
|
+
* simply never present.
|
|
19
|
+
*/
|
|
20
|
+
export const HEARTBEAT_STOP_FIELD = "stop_requested";
|
|
21
|
+
/**
|
|
22
|
+
* True ONLY for a literal `true` on the stop field. Anything else — absent,
|
|
23
|
+
* `false`, a string, a truthy non-boolean, or an unparseable body — means "no
|
|
24
|
+
* stop was requested", so an ambiguous body can never cancel a healthy run.
|
|
25
|
+
*/
|
|
26
|
+
function parseStopRequested(bodyText) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(bodyText);
|
|
29
|
+
if (!parsed || typeof parsed !== "object")
|
|
30
|
+
return false;
|
|
31
|
+
return parsed[HEARTBEAT_STOP_FIELD] === true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
15
37
|
/**
|
|
16
38
|
* Map a mutation (heartbeat/complete/fail) HTTP response onto the fencing
|
|
17
39
|
* outcome. `allowInvalidResult` maps 422 to `invalid_job_result` (complete only).
|
|
@@ -92,14 +114,33 @@ export function createExecutorHttpClient(config) {
|
|
|
92
114
|
// 5xx and any other unexpected status are retryable.
|
|
93
115
|
return { kind: "retryable", error: `claim failed (HTTP ${status}): ${boundedDetail(text)}` };
|
|
94
116
|
},
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
// Heartbeat does NOT go through `mutate()`: it is the one mutation whose
|
|
118
|
+
// response BODY carries information beyond the outcome (the BAPI-731 stop
|
|
119
|
+
// request), so it maps the response itself instead of discarding the body.
|
|
120
|
+
async heartbeat(job, payload) {
|
|
121
|
+
let status;
|
|
122
|
+
let text;
|
|
123
|
+
try {
|
|
124
|
+
const res = await post(`/${job.id}/heartbeat`, {
|
|
125
|
+
repo_name: job.repo_name,
|
|
126
|
+
claim_token: job.claim_token,
|
|
127
|
+
local_commit_count: payload.local_commit_count,
|
|
128
|
+
last_commit_sha: payload.last_commit_sha,
|
|
129
|
+
telemetry: payload.telemetry,
|
|
130
|
+
}, job.repo_name);
|
|
131
|
+
status = res.status;
|
|
132
|
+
text = res.text;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Transient network error — identical to the previous behavior; the
|
|
136
|
+
// caller's dead-man logic decides whether to keep the worker alive.
|
|
137
|
+
return { outcome: "fatal_http_error" };
|
|
138
|
+
}
|
|
139
|
+
const outcome = mapMutationResponse(status, text, false);
|
|
140
|
+
const result = { outcome };
|
|
141
|
+
if (parseStopRequested(text))
|
|
142
|
+
result.stop_requested = true;
|
|
143
|
+
return result;
|
|
103
144
|
},
|
|
104
145
|
complete(job, completion) {
|
|
105
146
|
return mutate(`/${job.id}/complete`, {
|
|
@@ -4,6 +4,10 @@ export const WorktreeLostBeforePush = "WorktreeLostBeforePush";
|
|
|
4
4
|
export const BranchMismatch = "BranchMismatch";
|
|
5
5
|
export const WorkerFinalizationMissingRemoteBranchAndPr = "WorkerFinalizationMissingRemoteBranchAndPr";
|
|
6
6
|
export const WorkerFinalizationPrBaseMismatch = "WorkerFinalizationPrBaseMismatch";
|
|
7
|
+
export const WorkerFinalizationSavedButUnfinalized = "WorkerFinalizationSavedButUnfinalized";
|
|
8
|
+
export const WorkerConfigIsolation = "ContractError.WorkerConfigIsolation";
|
|
9
|
+
export const PreSpawnVerification = "ContractError.PreSpawnVerification";
|
|
10
|
+
export const BridgeCredentialUnavailable = "ContractError.BridgeCredentialUnavailable";
|
|
7
11
|
/** Bound a failure message so no unbounded/secret-bearing text is posted. */
|
|
8
12
|
const ERROR_MESSAGE_MAX_CHARS = 300;
|
|
9
13
|
/**
|