@bridge_gpt/mcp-server 0.2.39 → 0.2.42
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 +10 -10
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +4 -4
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +75 -2
- package/build/conduct-epic/cli.js +795 -109
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/pr-state.js +113 -24
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +27 -1
- package/build/conductor/cli.js +46 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +15 -15
- package/build/credentials-cli.js +3 -2
- package/build/doctor.js +107 -41
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +148 -26
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +167 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +235 -48
- package/build/executor/test-clock.js +3 -2
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +153 -204
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +202 -38
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +3 -3
- package/build/plane/defaults.js +4 -1
- package/build/plane/preflight.js +81 -10
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +74 -1
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +453 -78
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
|
@@ -24,7 +24,7 @@ import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration
|
|
|
24
24
|
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
25
25
|
import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
|
|
26
26
|
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
27
|
-
import { runProcessWithTimeout } from "./process.js";
|
|
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
30
|
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
@@ -33,15 +33,17 @@ import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
|
33
33
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
34
34
|
import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
35
35
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
36
|
-
import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
36
|
+
import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
37
37
|
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
38
38
|
import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
39
39
|
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
40
|
+
import { resolveExecutorJobIndexScope } from "./index-scope.js";
|
|
40
41
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
41
42
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
42
43
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
43
44
|
import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
44
|
-
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, normalizeMcpServerNames, } from "./observation.js";
|
|
45
|
+
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
|
|
46
|
+
import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
|
|
45
47
|
/** Default runtime for the no-op smoke process (ms). */
|
|
46
48
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
47
49
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
@@ -249,7 +251,7 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
|
|
|
249
251
|
* Only server NAMES are read. The command, args, and env of each registration are
|
|
250
252
|
* never retained, so nothing that could carry a secret leaves this function.
|
|
251
253
|
*/
|
|
252
|
-
export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName =
|
|
254
|
+
export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = MCP_SERVER_NAME) {
|
|
253
255
|
// Normalize through the SAME path semantics the provisioner used to write the
|
|
254
256
|
// file, so the absolute path handed to `--mcp-config` is the one that actually
|
|
255
257
|
// exists on disk rather than a differently-spelled equivalent.
|
|
@@ -275,7 +277,17 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
|
|
|
275
277
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
276
278
|
}
|
|
277
279
|
const names = normalizeMcpServerNames(Object.keys(servers));
|
|
278
|
-
|
|
280
|
+
// BAPI-807: when the required name is Bridge's OWN registration, a
|
|
281
|
+
// permanently-supported legacy key satisfies it. A worktree provisioned
|
|
282
|
+
// before the rename (or re-entered from one) registers `bridge-api`, and
|
|
283
|
+
// refusing to spawn into it would turn the rename into an outage for exactly
|
|
284
|
+
// the users the legacy compatibility exists to protect. A non-Bridge
|
|
285
|
+
// `requiredServerName` (a future adapter's own runtime) is matched exactly,
|
|
286
|
+
// as before.
|
|
287
|
+
const satisfied = requiredServerName === MCP_SERVER_NAME
|
|
288
|
+
? names.some((name) => isRecognizedServerName(name))
|
|
289
|
+
: names.includes(requiredServerName);
|
|
290
|
+
if (!satisfied) {
|
|
279
291
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
280
292
|
}
|
|
281
293
|
return {
|
|
@@ -289,12 +301,6 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
|
|
|
289
301
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
290
302
|
}
|
|
291
303
|
}
|
|
292
|
-
/**
|
|
293
|
-
* Default required registration name, retained only as the parameter default so
|
|
294
|
-
* existing direct callers keep working. The SPAWN PATH always passes the
|
|
295
|
-
* adapter's declared name explicitly — it never relies on this default.
|
|
296
|
-
*/
|
|
297
|
-
const REQUIRED_MCP_SERVER_NAME = "bridge-api";
|
|
298
304
|
/**
|
|
299
305
|
* Refuse a spawn shape that is not internally consistent (BAPI-781).
|
|
300
306
|
*
|
|
@@ -336,7 +342,7 @@ function assertUsableSpawnShape(shape, adapter) {
|
|
|
336
342
|
}
|
|
337
343
|
}
|
|
338
344
|
/** Fixed, secret-free refusal text (no path, no file contents). */
|
|
339
|
-
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE =
|
|
345
|
+
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = `the required '${MCP_SERVER_NAME}' MCP registration is missing or unreadable in the prepared ` +
|
|
340
346
|
"worktree; a worker loads MCP servers strictly from that file, so refusing to spawn a " +
|
|
341
347
|
"worker that would run without Bridge tools";
|
|
342
348
|
/**
|
|
@@ -471,6 +477,9 @@ async function superviseProcess(params) {
|
|
|
471
477
|
const mcpSurface = params.mcpSurface;
|
|
472
478
|
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
473
479
|
termGraceMs: params.options.termGraceMs,
|
|
480
|
+
...(params.terminationController
|
|
481
|
+
? { terminationController: params.terminationController }
|
|
482
|
+
: {}),
|
|
474
483
|
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
475
484
|
...(mcpSurface
|
|
476
485
|
? {
|
|
@@ -503,12 +512,33 @@ function terminalToRunResult(terminal, successStatus) {
|
|
|
503
512
|
}
|
|
504
513
|
}
|
|
505
514
|
/** Dispatch and run a single claimed job. */
|
|
506
|
-
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {}
|
|
515
|
+
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
516
|
+
/**
|
|
517
|
+
* BAPI-828: the runner-scoped control object, carrying the live worker
|
|
518
|
+
* registry this job's worker registers with.
|
|
519
|
+
*
|
|
520
|
+
* A SEVENTH positional parameter, appended after the existing seams argument so
|
|
521
|
+
* every current caller — the runner's default `runJob`, the many tests that
|
|
522
|
+
* invoke this directly with four to six arguments — keeps compiling unchanged.
|
|
523
|
+
* Absent means no executor-level shutdown control, which is exactly right for a
|
|
524
|
+
* caller that has no executor loop around it.
|
|
525
|
+
*/
|
|
526
|
+
control) {
|
|
507
527
|
const ownership = {
|
|
508
528
|
abandoned: false,
|
|
509
529
|
lastSuccessfulHeartbeatAt: deps.now(),
|
|
510
530
|
};
|
|
511
|
-
const observation = createObservationState({ now: deps.now }, {
|
|
531
|
+
const observation = createObservationState({ now: deps.now }, {
|
|
532
|
+
advisoryParserEnabled: options.advisoryParserEnabled,
|
|
533
|
+
// BAPI-828: ONE line per job, from the per-job observation's first-wins
|
|
534
|
+
// retention rather than from a counter here. A worker nearing a weekly
|
|
535
|
+
// ceiling repeats the same event on every turn, and the operator needs the
|
|
536
|
+
// fact once. Emitting is all this does — no claim, retry, lease, or
|
|
537
|
+
// classification decision reads it.
|
|
538
|
+
onWorkerRateLimitAdvisory: (advisory) => {
|
|
539
|
+
deps.errorLog(formatWorkerRateLimitAdvisory(advisory));
|
|
540
|
+
},
|
|
541
|
+
});
|
|
512
542
|
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
513
543
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
514
544
|
// worker (TDD §5/§9).
|
|
@@ -530,7 +560,7 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
530
560
|
// are stateless with respect to the host, which is why the whole
|
|
531
561
|
// establish/dispose lifetime this branch used to manage simply stopped
|
|
532
562
|
// existing rather than moving somewhere else.
|
|
533
|
-
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
563
|
+
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
534
564
|
}
|
|
535
565
|
// Unsupported job type — fail server-side; never spawn.
|
|
536
566
|
await httpClient.fail(job, {
|
|
@@ -915,7 +945,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
915
945
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
916
946
|
* touches a worktree that will not become a runnable worker.
|
|
917
947
|
*/
|
|
918
|
-
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
948
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control) {
|
|
919
949
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
920
950
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
921
951
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -971,6 +1001,23 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
971
1001
|
}
|
|
972
1002
|
const effectiveBaseBranch = baseResolution.baseBranch;
|
|
973
1003
|
const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
|
|
1004
|
+
// --- Declared index scope (BAPI-844) ---------------------------------
|
|
1005
|
+
// Resolved here, alongside the base branch and for the same reason: a job that
|
|
1006
|
+
// declares a scope it cannot be routed by must fail before a worktree is cut, a
|
|
1007
|
+
// deny layer is written, MCP is provisioned, a log is opened, or a process is
|
|
1008
|
+
// spawned. An ABSENT declaration is the ordinary unscoped job and adds nothing
|
|
1009
|
+
// anywhere downstream. The error text names the field only — the declared value
|
|
1010
|
+
// is opaque and never reaches `/fail`.
|
|
1011
|
+
const indexScopeResolution = resolveExecutorJobIndexScope(job);
|
|
1012
|
+
if (!indexScopeResolution.ok) {
|
|
1013
|
+
await httpClient.fail(job, {
|
|
1014
|
+
error_kind: "ContractError.IndexScope",
|
|
1015
|
+
error_message: indexScopeResolution.error,
|
|
1016
|
+
classification: "crashed",
|
|
1017
|
+
});
|
|
1018
|
+
return { status: "failed", reason: "index_scope_contract" };
|
|
1019
|
+
}
|
|
1020
|
+
const indexScope = indexScopeResolution.indexScope;
|
|
974
1021
|
// --- Permission posture (BAPI-725) -----------------------------------
|
|
975
1022
|
// Resolved alongside the base branch, BEFORE any side effect, for the same
|
|
976
1023
|
// reason: an unsupported posture is a contract failure, and failing it here
|
|
@@ -1032,6 +1079,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1032
1079
|
});
|
|
1033
1080
|
return { status: "failed", reason: "worktree_busy" };
|
|
1034
1081
|
}
|
|
1082
|
+
// BAPI-828: the post-spawn lifecycle handles this job's worker owns, filled in
|
|
1083
|
+
// by `runPreparedSpawn` as each resource comes into existence and unwound in
|
|
1084
|
+
// the `finally` below. A shared mutable record rather than return values,
|
|
1085
|
+
// because the resources must be released even when `runPreparedSpawn` throws —
|
|
1086
|
+
// which is exactly the path that has no return value to inspect.
|
|
1087
|
+
const spawnLifecycle = {};
|
|
1035
1088
|
try {
|
|
1036
1089
|
// --- Stale verdict artifacts (BAPI-814/D1) --------------------------
|
|
1037
1090
|
// Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
|
|
@@ -1078,14 +1131,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1078
1131
|
branch,
|
|
1079
1132
|
prep,
|
|
1080
1133
|
effectiveBaseBranch,
|
|
1134
|
+
indexScope,
|
|
1081
1135
|
permissionPosture,
|
|
1082
1136
|
adapter,
|
|
1137
|
+
control,
|
|
1138
|
+
lifecycle: spawnLifecycle,
|
|
1083
1139
|
});
|
|
1084
1140
|
}
|
|
1085
1141
|
finally {
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1142
|
+
// BAPI-828 — cleanup ORDER is the contract, and it runs strictly after
|
|
1143
|
+
// `runPreparedSpawn` has settled (normally or by throwing), which means
|
|
1144
|
+
// strictly after process supervision has settled:
|
|
1145
|
+
//
|
|
1146
|
+
// 1. Deregister the worker. It is no longer live, so a shutdown arriving
|
|
1147
|
+
// now must not try to signal it — the registry should only ever hold
|
|
1148
|
+
// children that might still be running.
|
|
1149
|
+
// 2. Close and deactivate the worker log. Idempotent: the terminal branches
|
|
1150
|
+
// inside `runPreparedSpawn` already close it on their way out, and this
|
|
1151
|
+
// is the backstop for the exceptional paths that do not.
|
|
1152
|
+
// 3. Release the worktree lock — LAST, and still through the same
|
|
1153
|
+
// ownership-checked `release()` as before this ticket. The worktree must
|
|
1154
|
+
// never be advertised as free while this job's worker could still be
|
|
1155
|
+
// touching it, and the two steps above are what establish that it cannot.
|
|
1156
|
+
spawnLifecycle.deregisterLiveWorker?.();
|
|
1157
|
+
spawnLifecycle.deregisterLiveWorker = undefined;
|
|
1158
|
+
await spawnLifecycle.closeWorkerLog?.();
|
|
1089
1159
|
await worktreeLock.release();
|
|
1090
1160
|
}
|
|
1091
1161
|
}
|
|
@@ -1100,7 +1170,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1100
1170
|
* rather than a 500-line indentation nobody can verify by eye.
|
|
1101
1171
|
*/
|
|
1102
1172
|
async function runPreparedSpawn(params) {
|
|
1103
|
-
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
|
|
1173
|
+
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
|
|
1104
1174
|
// BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
|
|
1105
1175
|
// are told to open the PR against the injected run base. Verdict-only
|
|
1106
1176
|
// `spec_review` produces no PR, so it is excluded from the instruction while
|
|
@@ -1247,7 +1317,31 @@ async function runPreparedSpawn(params) {
|
|
|
1247
1317
|
catch {
|
|
1248
1318
|
tee = null;
|
|
1249
1319
|
}
|
|
1320
|
+
// BAPI-828 — idempotent worker-log closure, defined the moment a tee could
|
|
1321
|
+
// exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
|
|
1322
|
+
// directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
|
|
1323
|
+
// the paths that throw. Before this ticket the close was open-coded at eleven
|
|
1324
|
+
// separate return sites, which is why the active-log deactivation is folded in
|
|
1325
|
+
// here rather than added as a twelfth thing each branch had to remember.
|
|
1326
|
+
let workerLogClosed = false;
|
|
1327
|
+
const closeWorkerLog = async () => {
|
|
1328
|
+
if (workerLogClosed)
|
|
1329
|
+
return;
|
|
1330
|
+
workerLogClosed = true;
|
|
1331
|
+
// Deactivate FIRST: once the tee is closing, an append from the suspend
|
|
1332
|
+
// diagnostic would be writing to a log nobody is reading any more.
|
|
1333
|
+
deactivateJobLog(job.id);
|
|
1334
|
+
await closeWorkerLogTee(tee);
|
|
1335
|
+
};
|
|
1336
|
+
lifecycle.closeWorkerLog = closeWorkerLog;
|
|
1250
1337
|
if (tee) {
|
|
1338
|
+
// BAPI-828 — mark the log appendable as soon as the tee EXISTS, and
|
|
1339
|
+
// deliberately BEFORE the watch-registry persistence below. The two are
|
|
1340
|
+
// independent on purpose: `registerLog` swallows its own failures (it is a
|
|
1341
|
+
// visibility nicety for a separate `executor watch` process), and chaining
|
|
1342
|
+
// the active marker to it would mean a failed registry write silently
|
|
1343
|
+
// disables suspend annotation for a worker whose tee is perfectly healthy.
|
|
1344
|
+
activateJobLog(job.id, tee.logPath);
|
|
1251
1345
|
const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
|
|
1252
1346
|
try {
|
|
1253
1347
|
await registerLog({
|
|
@@ -1312,9 +1406,16 @@ async function runPreparedSpawn(params) {
|
|
|
1312
1406
|
//
|
|
1313
1407
|
// BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
|
|
1314
1408
|
// worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
1409
|
+
//
|
|
1410
|
+
// BAPI-844: the validated declared index scope travels the same way, as an
|
|
1411
|
+
// explicit option rather than through the environment this process happens to
|
|
1412
|
+
// hold — the executor's own `BAPI_INDEX_SCOPE`, if an operator exported one,
|
|
1413
|
+
// is denied at the env builder and cannot reach `spawnShape.env`. It stays out
|
|
1414
|
+
// of `argv` entirely: it is a routing declaration, not a command-line input,
|
|
1415
|
+
// and argv is world-readable via `ps`.
|
|
1315
1416
|
const invocation = adapter.headlessInvocation;
|
|
1316
1417
|
if (invocation?.supported !== true) {
|
|
1317
|
-
await
|
|
1418
|
+
await closeWorkerLog();
|
|
1318
1419
|
await finalizeRegistry();
|
|
1319
1420
|
await httpClient.fail(job, {
|
|
1320
1421
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1332,6 +1433,7 @@ async function runPreparedSpawn(params) {
|
|
|
1332
1433
|
posture: permissionPosture,
|
|
1333
1434
|
mcpConfigPath: mcpContract.configPath,
|
|
1334
1435
|
effectiveBaseBranch,
|
|
1436
|
+
indexScope,
|
|
1335
1437
|
parentEnv: deps.env,
|
|
1336
1438
|
});
|
|
1337
1439
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -1342,7 +1444,7 @@ async function runPreparedSpawn(params) {
|
|
|
1342
1444
|
// a missing argv from Claude's flags is precisely the defaulting this
|
|
1343
1445
|
// extraction removes, and it would spawn a real worker under a shape nobody
|
|
1344
1446
|
// declared.
|
|
1345
|
-
await
|
|
1447
|
+
await closeWorkerLog();
|
|
1346
1448
|
await finalizeRegistry();
|
|
1347
1449
|
await httpClient.fail(job, {
|
|
1348
1450
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1360,7 +1462,7 @@ async function runPreparedSpawn(params) {
|
|
|
1360
1462
|
// certify its own containment.
|
|
1361
1463
|
const initParsing = adapter.mcpInitParsing;
|
|
1362
1464
|
if (initParsing?.supported !== true) {
|
|
1363
|
-
await
|
|
1465
|
+
await closeWorkerLog();
|
|
1364
1466
|
await finalizeRegistry();
|
|
1365
1467
|
await httpClient.fail(job, {
|
|
1366
1468
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1378,7 +1480,7 @@ async function runPreparedSpawn(params) {
|
|
|
1378
1480
|
});
|
|
1379
1481
|
}
|
|
1380
1482
|
catch (err) {
|
|
1381
|
-
await
|
|
1483
|
+
await closeWorkerLog();
|
|
1382
1484
|
await finalizeRegistry();
|
|
1383
1485
|
// Redact through the adapter's declared secret names before bounding. A
|
|
1384
1486
|
// spawn error can echo the environment it failed to apply, and that
|
|
@@ -1394,6 +1496,22 @@ async function runPreparedSpawn(params) {
|
|
|
1394
1496
|
});
|
|
1395
1497
|
return { status: "failed", reason: "spawn_failed" };
|
|
1396
1498
|
}
|
|
1499
|
+
// --- Live worker registration (BAPI-828) ------------------------------
|
|
1500
|
+
// Immediately after the ONE successful `deps.spawnProcess` above, and strictly
|
|
1501
|
+
// before any asynchronous supervision begins. That ordering is the whole
|
|
1502
|
+
// guarantee: between the spawn returning and supervision starting there is real
|
|
1503
|
+
// async work (the tee wrapper, the telemetry closures), and a `SIGTERM` landing
|
|
1504
|
+
// in that window used to find a live child that nothing owned.
|
|
1505
|
+
//
|
|
1506
|
+
// The controller is created against the RAW `proc`, not the tee-wrapped view
|
|
1507
|
+
// below: signals go to the actual child, while the wrapper exists only to
|
|
1508
|
+
// duplicate its output.
|
|
1509
|
+
const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
|
|
1510
|
+
// Registration may terminate the worker SYNCHRONOUSLY when shutdown was already
|
|
1511
|
+
// requested — the sticky case, where the signal arrived while this job was still
|
|
1512
|
+
// preparing its worktree. That is intended: the child is signalled here, and
|
|
1513
|
+
// supervision below still runs normally to observe its exit and report it.
|
|
1514
|
+
lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
|
|
1397
1515
|
// Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
|
|
1398
1516
|
const supervisedProc = tee
|
|
1399
1517
|
? {
|
|
@@ -1419,6 +1537,10 @@ async function runPreparedSpawn(params) {
|
|
|
1419
1537
|
collectTelemetry,
|
|
1420
1538
|
collectRemoteMarker,
|
|
1421
1539
|
mcpSurface,
|
|
1540
|
+
// The SAME controller the registry holds, so an executor shutdown, a timeout,
|
|
1541
|
+
// and an MCP containment refusal all converge on one `SIGTERM` and one grace
|
|
1542
|
+
// timer for this child (BAPI-828).
|
|
1543
|
+
terminationController,
|
|
1422
1544
|
});
|
|
1423
1545
|
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
1424
1546
|
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
@@ -1458,7 +1580,7 @@ async function runPreparedSpawn(params) {
|
|
|
1458
1580
|
// `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
|
|
1459
1581
|
// the terminal mutation is attempted, so the report goes out under a live claim.
|
|
1460
1582
|
if (procResult.serverStopRequested) {
|
|
1461
|
-
await
|
|
1583
|
+
await closeWorkerLog();
|
|
1462
1584
|
await finalizeRegistry();
|
|
1463
1585
|
await httpClient.fail(job, {
|
|
1464
1586
|
error_kind: "ServerStopRequested",
|
|
@@ -1472,7 +1594,7 @@ async function runPreparedSpawn(params) {
|
|
|
1472
1594
|
return { status: "abandoned", reason: "server_stop" };
|
|
1473
1595
|
}
|
|
1474
1596
|
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
1475
|
-
await
|
|
1597
|
+
await closeWorkerLog();
|
|
1476
1598
|
// --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
|
|
1477
1599
|
// "`.conductor/worker.log` for any completed job contains exactly one
|
|
1478
1600
|
// `session_id`" — the cheap, durable invariant from the ticket that would have
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner-scoped live worker registry (BAPI-828).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS FIXES. Before this module the executor had no handle on its own
|
|
5
|
+
* children. `SIGTERM` to the executor killed the executor and left the Claude
|
|
6
|
+
* worker it had spawned running, still holding the worktree, still committing
|
|
7
|
+
* into it, with the worktree lock left behind by a process that no longer
|
|
8
|
+
* existed. The operator runbook's answer was to run `pkill -TERM -P <executor
|
|
9
|
+
* pid>` BEFORE stopping the executor — a manual step that is exactly as
|
|
10
|
+
* reliable as remembering it.
|
|
11
|
+
*
|
|
12
|
+
* SCOPE IS PER `runExecutor` INVOCATION, NOT MODULE-GLOBAL. A module-level set
|
|
13
|
+
* would outlive the invocation that filled it, so a second `runExecutor` call in
|
|
14
|
+
* the same process (the `--once` path, an embedded runner, a test) would inherit
|
|
15
|
+
* another run's children and could terminate them. The registry is created by
|
|
16
|
+
* `runExecutor` and travels to each dispatched job through
|
|
17
|
+
* {@link ExecutorRunControl}; when the invocation returns, the registry is
|
|
18
|
+
* garbage, which is the correct lifetime.
|
|
19
|
+
*
|
|
20
|
+
* SHUTDOWN IS STICKY, AND THAT IS THE POINT. A signal can arrive while a claimed
|
|
21
|
+
* job is still preparing its worktree — seconds before it spawns anything. A
|
|
22
|
+
* plain "terminate everyone registered right now" would miss that worker
|
|
23
|
+
* entirely and leave precisely the orphan this module exists to prevent. So the
|
|
24
|
+
* shutdown flag persists: a controller registered AFTER shutdown was requested
|
|
25
|
+
* is terminated immediately, on registration.
|
|
26
|
+
*
|
|
27
|
+
* TERMINATION IS REQUESTED EXACTLY ONCE PER CONTROLLER. The controllers
|
|
28
|
+
* themselves are idempotent, but the registry does not lean on that — it tracks
|
|
29
|
+
* what it has already asked for, so repeated or mixed `SIGTERM`/`SIGINT` cannot
|
|
30
|
+
* produce a second request even against a controller implementation that would
|
|
31
|
+
* have honored one.
|
|
32
|
+
*
|
|
33
|
+
* SECRET-FREE. The registry holds termination controllers and nothing else — no
|
|
34
|
+
* job payload, no claim token, no argv, no environment.
|
|
35
|
+
*/
|
|
36
|
+
/** Create an empty, runner-scoped live worker registry. */
|
|
37
|
+
export function createLiveWorkerRegistry() {
|
|
38
|
+
const live = new Set();
|
|
39
|
+
const alreadyRequested = new Set();
|
|
40
|
+
let shutdownRequested = false;
|
|
41
|
+
const terminateOnce = (controller) => {
|
|
42
|
+
if (alreadyRequested.has(controller))
|
|
43
|
+
return;
|
|
44
|
+
alreadyRequested.add(controller);
|
|
45
|
+
try {
|
|
46
|
+
controller.requestTermination();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Fail-open, and deliberately silent. A controller that throws is a defect
|
|
50
|
+
// in one worker's teardown; letting it escape would abort the shutdown
|
|
51
|
+
// drain and strand every OTHER live worker — turning one bad teardown into
|
|
52
|
+
// the exact orphan situation this registry prevents.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
register(controller) {
|
|
57
|
+
live.add(controller);
|
|
58
|
+
if (shutdownRequested)
|
|
59
|
+
terminateOnce(controller);
|
|
60
|
+
return () => {
|
|
61
|
+
live.delete(controller);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
deregister(controller) {
|
|
65
|
+
live.delete(controller);
|
|
66
|
+
},
|
|
67
|
+
requestShutdown() {
|
|
68
|
+
shutdownRequested = true;
|
|
69
|
+
// Snapshot before iterating: `terminateOnce` cannot mutate `live`, but a
|
|
70
|
+
// controller's own termination path may synchronously reach code that
|
|
71
|
+
// deregisters, and mutating a Set mid-iteration is not a behavior to rely
|
|
72
|
+
// on.
|
|
73
|
+
for (const controller of [...live])
|
|
74
|
+
terminateOnce(controller);
|
|
75
|
+
},
|
|
76
|
+
isShutdownRequested() {
|
|
77
|
+
return shutdownRequested;
|
|
78
|
+
},
|
|
79
|
+
size() {
|
|
80
|
+
return live.size;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -166,9 +166,93 @@ export async function collectRemoteTrackingSha(deps, worktreePath, branch) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
169
|
+
* Utilization at which a still-`allowed` rate limit becomes worth reporting
|
|
170
|
+
* (BAPI-828).
|
|
171
|
+
*
|
|
172
|
+
* The comparison is `>=`, not `>`: a provider that reports
|
|
173
|
+
* `surpassedThreshold: 0.75` alongside `utilization: 0.75` is telling us the
|
|
174
|
+
* threshold was reached, and treating exactly-at-threshold as uninteresting would
|
|
175
|
+
* drop the very first warning the provider bothered to send.
|
|
176
|
+
*/
|
|
177
|
+
export const RATE_LIMIT_WARNING_UTILIZATION = 0.75;
|
|
178
|
+
/**
|
|
179
|
+
* Character cap on each worker-supplied rate-limit string.
|
|
180
|
+
*
|
|
181
|
+
* SMALL ON PURPOSE, unlike the surface observer's line buffer above. These two
|
|
182
|
+
* values are persisted into job telemetry and interpolated into a stderr line, so
|
|
183
|
+
* the bound is a content limit rather than a stop-unbounded-growth limit. Real
|
|
184
|
+
* values (`seven_day`, `allowed_warning`) are an order of magnitude under it.
|
|
185
|
+
*/
|
|
186
|
+
export const MAX_RATE_LIMIT_FIELD_CHARS = 64;
|
|
187
|
+
/**
|
|
188
|
+
* Normalize one worker-controlled rate-limit string, or `null` when unusable.
|
|
189
|
+
*
|
|
190
|
+
* Newlines and tabs collapse to spaces BEFORE truncation. That ordering matters:
|
|
191
|
+
* the sanitized value is interpolated into a single-line stderr diagnostic, and a
|
|
192
|
+
* worker that embedded `\n executor: ...` could otherwise forge an executor
|
|
193
|
+
* diagnostic line in the operator's terminal and in the worker log.
|
|
194
|
+
*/
|
|
195
|
+
function sanitizeRateLimitField(value) {
|
|
196
|
+
if (typeof value !== "string")
|
|
197
|
+
return null;
|
|
198
|
+
const collapsed = value.replace(/[\r\n\t]+/g, " ").trim();
|
|
199
|
+
if (collapsed.length === 0)
|
|
200
|
+
return null;
|
|
201
|
+
return collapsed.slice(0, MAX_RATE_LIMIT_FIELD_CHARS);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Recognize a QUALIFYING `rate_limit_event`, or `null` for everything else.
|
|
205
|
+
*
|
|
206
|
+
* Qualification is deliberately two-sided: utilization at or above the warning
|
|
207
|
+
* threshold, OR a status that is not exactly `allowed`. Either alone would miss a
|
|
208
|
+
* real case — a hard `blocked` at low utilization is the most actionable event
|
|
209
|
+
* there is, and a provider that keeps saying `allowed` right up to the ceiling
|
|
210
|
+
* still deserves a warning at 90%.
|
|
211
|
+
*
|
|
212
|
+
* Everything unrecognized returns `null` rather than throwing: this is the
|
|
213
|
+
* advisory parser, and R5 says a parser that throws is a bug.
|
|
214
|
+
*/
|
|
215
|
+
function parseRateLimitEvent(obj) {
|
|
216
|
+
if (obj.type !== "rate_limit_event")
|
|
217
|
+
return null;
|
|
218
|
+
const info = obj.rate_limit_info;
|
|
219
|
+
if (!info || typeof info !== "object" || Array.isArray(info))
|
|
220
|
+
return null;
|
|
221
|
+
const record = info;
|
|
222
|
+
const rateLimitType = sanitizeRateLimitField(record.rateLimitType);
|
|
223
|
+
const status = sanitizeRateLimitField(record.status);
|
|
224
|
+
if (rateLimitType === null || status === null)
|
|
225
|
+
return null;
|
|
226
|
+
const utilization = record.utilization;
|
|
227
|
+
// `Number.isFinite` rejects NaN and both infinities as well as non-numbers, so a
|
|
228
|
+
// malformed value can never reach the percentage arithmetic below.
|
|
229
|
+
if (typeof utilization !== "number" || !Number.isFinite(utilization))
|
|
230
|
+
return null;
|
|
231
|
+
const qualifies = utilization >= RATE_LIMIT_WARNING_UTILIZATION || status !== "allowed";
|
|
232
|
+
if (!qualifies)
|
|
233
|
+
return null;
|
|
234
|
+
return { rate_limit_type: rateLimitType, status, utilization };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Render the operator-facing rate-limit line.
|
|
238
|
+
*
|
|
239
|
+
* Exported so the stderr text has ONE definition rather than being rebuilt at the
|
|
240
|
+
* call site — the job runner emits it, and the tests pin it, from here.
|
|
241
|
+
*/
|
|
242
|
+
export function formatWorkerRateLimitAdvisory(advisory) {
|
|
243
|
+
const percent = Math.round(advisory.utilization * 100);
|
|
244
|
+
return (`executor: worker reports ${advisory.rate_limit_type} rate limit at ` +
|
|
245
|
+
`${percent}% (${advisory.status})`);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Tolerantly parse one Claude stream-json line for advisory signals.
|
|
170
249
|
* Catches ALL parse/schema errors and never throws; returns `{}` when nothing
|
|
171
250
|
* usable is found.
|
|
251
|
+
*
|
|
252
|
+
* BAPI-828: a line may now yield BOTH a `phase_hint` and a `rate_limit` record.
|
|
253
|
+
* They are independent observations of the same event, so neither suppresses the
|
|
254
|
+
* other — a `rate_limit_event` still contributes its type as a phase hint exactly
|
|
255
|
+
* as it did before this ticket.
|
|
172
256
|
*/
|
|
173
257
|
export function parseClaudeStreamJsonLine(line) {
|
|
174
258
|
try {
|
|
@@ -179,11 +263,17 @@ export function parseClaudeStreamJsonLine(line) {
|
|
|
179
263
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
180
264
|
return {};
|
|
181
265
|
const obj = parsed;
|
|
266
|
+
const out = {};
|
|
182
267
|
// Advisory only: prefer an explicit phase/subtype, else the stream event type.
|
|
183
268
|
const hint = pickString(obj.phase) ??
|
|
184
269
|
pickString(obj.subtype) ??
|
|
185
270
|
pickString(obj.type);
|
|
186
|
-
|
|
271
|
+
if (hint)
|
|
272
|
+
out.phase_hint = hint;
|
|
273
|
+
const rateLimit = parseRateLimitEvent(obj);
|
|
274
|
+
if (rateLimit)
|
|
275
|
+
out.rate_limit = rateLimit;
|
|
276
|
+
return out;
|
|
187
277
|
}
|
|
188
278
|
catch {
|
|
189
279
|
return {};
|
|
@@ -357,6 +447,13 @@ export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
|
|
|
357
447
|
},
|
|
358
448
|
};
|
|
359
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Cap on the partial advisory line the observation state will buffer, in
|
|
452
|
+
* characters. Mirrors {@link MAX_BUFFERED_SURFACE_LINE_CHARS} and exists for the
|
|
453
|
+
* same reason: hold one complete stream-json line intact so a chunk boundary
|
|
454
|
+
* cannot hide an event, while still refusing to grow without bound.
|
|
455
|
+
*/
|
|
456
|
+
export const MAX_BUFFERED_ADVISORY_LINE_CHARS = 1_048_576;
|
|
360
457
|
/**
|
|
361
458
|
* Create the observation state. When `advisoryParserEnabled` is false, stdout is
|
|
362
459
|
* still timestamped (`last_stdout_at`) but never parsed — proving the advisory
|
|
@@ -368,16 +465,75 @@ export function createObservationState(deps, options) {
|
|
|
368
465
|
let exitCode;
|
|
369
466
|
let attemptStartSha;
|
|
370
467
|
let attemptEndSha;
|
|
468
|
+
/** BAPI-828: the FIRST qualifying advisory wins; later ones never overwrite it. */
|
|
469
|
+
let rateLimit;
|
|
470
|
+
/** Residual partial line carried across stdout chunks (BAPI-828). */
|
|
471
|
+
let buffer = "";
|
|
472
|
+
/** True while skipping the remainder of a line that blew the size cap. */
|
|
473
|
+
let discardingOversizedLine = false;
|
|
474
|
+
const consumeAdvisoryLine = (line) => {
|
|
475
|
+
const parsed = parseClaudeStreamJsonLine(line);
|
|
476
|
+
if (parsed.phase_hint)
|
|
477
|
+
advisory.phase_hint = parsed.phase_hint;
|
|
478
|
+
if (parsed.rate_limit && rateLimit === undefined) {
|
|
479
|
+
rateLimit = parsed.rate_limit;
|
|
480
|
+
try {
|
|
481
|
+
options.onWorkerRateLimitAdvisory?.(rateLimit);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// The callback only logs. A throwing one must not propagate out of the
|
|
485
|
+
// stdout pump, where it would be swallowed as a stream error and
|
|
486
|
+
// silently disable observation for the rest of the run.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
371
490
|
return {
|
|
372
491
|
recordStdout(chunk) {
|
|
373
492
|
advisory.last_stdout_at = new Date(deps.now()).toISOString();
|
|
374
493
|
if (!options.advisoryParserEnabled)
|
|
375
494
|
return;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
495
|
+
if (typeof chunk !== "string" || chunk.length === 0)
|
|
496
|
+
return;
|
|
497
|
+
// BAPI-828: line assembly replaces the previous per-chunk `split("\n")`.
|
|
498
|
+
// stdout arrives in arbitrary chunks, and a `rate_limit_event` split across
|
|
499
|
+
// two reads used to be parsed as two malformed halves and lost. Phase hints
|
|
500
|
+
// tolerated that (the next event supplied another); a rate-limit event may
|
|
501
|
+
// legitimately occur once in a whole run, so losing it loses the signal.
|
|
502
|
+
let input = chunk;
|
|
503
|
+
// Still skipping past a line that already blew the cap. Resync ON THE
|
|
504
|
+
// NEWLINE rather than merely clearing, so the dead line's tail cannot be
|
|
505
|
+
// parsed as though it were a fresh event.
|
|
506
|
+
if (discardingOversizedLine) {
|
|
507
|
+
const resyncAt = input.indexOf("\n");
|
|
508
|
+
if (resyncAt === -1)
|
|
509
|
+
return;
|
|
510
|
+
discardingOversizedLine = false;
|
|
511
|
+
input = input.slice(resyncAt + 1);
|
|
512
|
+
}
|
|
513
|
+
buffer += input;
|
|
514
|
+
let newlineAt = buffer.indexOf("\n");
|
|
515
|
+
while (newlineAt !== -1) {
|
|
516
|
+
consumeAdvisoryLine(buffer.slice(0, newlineAt));
|
|
517
|
+
buffer = buffer.slice(newlineAt + 1);
|
|
518
|
+
newlineAt = buffer.indexOf("\n");
|
|
519
|
+
}
|
|
520
|
+
if (buffer.length > MAX_BUFFERED_ADVISORY_LINE_CHARS) {
|
|
521
|
+
// Unparseable by definition, and not worth carrying. Drop it and resync;
|
|
522
|
+
// a LATER valid line still parses normally, so one pathological line
|
|
523
|
+
// cannot disable advisory observation for the rest of the run.
|
|
524
|
+
buffer = "";
|
|
525
|
+
discardingOversizedLine = true;
|
|
526
|
+
return;
|
|
380
527
|
}
|
|
528
|
+
// Parse the residual NON-destructively. A worker whose final chunk carries
|
|
529
|
+
// no trailing newline would otherwise strand its last event in the buffer
|
|
530
|
+
// forever — and the pre-BAPI-828 parser DID read that trailing fragment, so
|
|
531
|
+
// skipping it would be a silent regression in phase-hint reporting. Keeping
|
|
532
|
+
// the buffer means the same bytes are re-parsed once the rest arrives, which
|
|
533
|
+
// is harmless: a truncated line does not parse, and the first-wins guard
|
|
534
|
+
// above makes a repeat parse of a complete line a no-op.
|
|
535
|
+
if (buffer.length > 0)
|
|
536
|
+
consumeAdvisoryLine(buffer);
|
|
381
537
|
},
|
|
382
538
|
setGitTelemetry(telemetry) {
|
|
383
539
|
git = telemetry;
|
|
@@ -404,6 +560,11 @@ export function createObservationState(deps, options) {
|
|
|
404
560
|
residue.phase_hint = advisory.phase_hint;
|
|
405
561
|
if (advisory.last_stdout_at)
|
|
406
562
|
residue.last_stdout_at = advisory.last_stdout_at;
|
|
563
|
+
// BAPI-828: rides the existing envelope into heartbeats and terminal
|
|
564
|
+
// mutations. Absent when no qualifying event was seen, so a job that never
|
|
565
|
+
// approached a limit serializes byte-identically to before this ticket.
|
|
566
|
+
if (rateLimit !== undefined)
|
|
567
|
+
residue.rate_limit = rateLimit;
|
|
407
568
|
if (exitCode !== undefined)
|
|
408
569
|
residue.exit_code = exitCode;
|
|
409
570
|
// The attempt boundary pair is independent of `last_commit_sha`: it
|