@bridge_gpt/mcp-server 0.2.42 → 0.2.43

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.
@@ -817,6 +817,35 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
817
817
  });
818
818
  return terminalToRunResult(terminal, "completed");
819
819
  }
820
+ /**
821
+ * BAPI-862: the payload key the reconciler stamps with the implement gate's last
822
+ * observation reason code. Server-minted and opaque to the executor beyond the
823
+ * closed set below — the executor never derives, defaults, or infers it.
824
+ */
825
+ const IMPLEMENT_LAST_OBSERVATION_KEY = "implement_last_observation_reason";
826
+ /**
827
+ * The one observation reason code that changes the stale-branch guard's advice.
828
+ * Matches `PR_ATTACH_PENDING_REASON_CODE` in the reconciler; the two strings are
829
+ * one vocabulary on purpose, so an operator greps a single term across the job
830
+ * row, the gate observation, and the executor refusal.
831
+ */
832
+ const PR_NOT_ATTACHED_REASON_CODE = "pr_not_attached";
833
+ /**
834
+ * Map the job payload's reconciliation observation onto the guard's narrow
835
+ * classification. Anything absent, malformed, or unrecognized degrades to
836
+ * `"unknown"` — the pre-BAPI-862 delete/rebase guidance. That direction is
837
+ * deliberate: the softened message asserts the branch holds finished work, and
838
+ * asserting that on a guess would talk an operator OUT of cleaning up a genuinely
839
+ * stale branch.
840
+ */
841
+ function resolveStaleBranchClassification(payload) {
842
+ if (!payload || typeof payload !== "object")
843
+ return "unknown";
844
+ const raw = payload[IMPLEMENT_LAST_OBSERVATION_KEY];
845
+ if (typeof raw !== "string")
846
+ return "unknown";
847
+ return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
848
+ }
820
849
  async function prepareSpawn(job, httpClient, options, deps, seams) {
821
850
  if (job.job_type === "resume") {
822
851
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
@@ -883,7 +912,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
883
912
  // `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
884
913
  // here — it has its own preservation protocol above.
885
914
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
886
- const wt = await ensureWorktree(job, options, deps, { reuseExistingBranch });
915
+ const wt = await ensureWorktree(job, options, deps, {
916
+ reuseExistingBranch,
917
+ // BAPI-862: hand the guard the reconciler's own reading of why this branch
918
+ // may already carry commits. Read from the SERVER-minted payload field and
919
+ // validated against a closed set — never inferred locally, and never taken
920
+ // from worker output (R14 rules 3 and 4).
921
+ staleBranchClassification: resolveStaleBranchClassification(job.payload),
922
+ });
887
923
  if (!wt.ok) {
888
924
  await httpClient.fail(job, {
889
925
  error_kind: "WorktreeError",
@@ -1743,7 +1779,7 @@ async function runPreparedSpawn(params) {
1743
1779
  // the verdict-artifact path above stays limited to verdict jobs like
1744
1780
  // `spec_review`. Recovery jobs complete through this same generic envelope.
1745
1781
  const artifacts = await readCompletionArtifacts(worktreePath, deps);
1746
- const result = buildGenericSuccessResult({
1782
+ const genericResult = buildGenericSuccessResult({
1747
1783
  summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
1748
1784
  branch,
1749
1785
  headSha: git.last_commit_sha,
@@ -1757,7 +1793,7 @@ async function runPreparedSpawn(params) {
1757
1793
  job,
1758
1794
  branch,
1759
1795
  worktreePath,
1760
- result,
1796
+ result: genericResult,
1761
1797
  runCommand: deps.runCommand,
1762
1798
  headSha: git.last_commit_sha,
1763
1799
  expectedBaseBranch: effectiveBaseBranch,
@@ -1778,6 +1814,17 @@ async function runPreparedSpawn(params) {
1778
1814
  });
1779
1815
  return terminalToRunResult(terminal, "failed");
1780
1816
  }
1817
+ // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
1818
+ // populated on this path before — `buildGenericSuccessResult` has no PR field
1819
+ // at all — which is why job 1397's stored `result.pr_url` was null even though
1820
+ // the reconciler's whole implement gate is waiting on that identity. The value
1821
+ // comes strictly from the authoritative `gh` observation finalization just
1822
+ // made; worker prose is never admissible here (R14 rule 3). It is omitted
1823
+ // rather than set to null when finalization had no PR to verify (a
1824
+ // non-implementation job), so the field's presence always means "verified".
1825
+ const result = typeof finalization.prUrl === "string"
1826
+ ? { ...genericResult, pr_url: finalization.prUrl }
1827
+ : genericResult;
1781
1828
  const completion = {
1782
1829
  job_type: job.job_type,
1783
1830
  exit_code: procResult.exitCode ?? 0,
@@ -200,6 +200,50 @@ function sanitizeRateLimitField(value) {
200
200
  return null;
201
201
  return collapsed.slice(0, MAX_RATE_LIMIT_FIELD_CHARS);
202
202
  }
203
+ /**
204
+ * Safely derive an ISO-8601 UTC reset timestamp from worker-controlled input, or
205
+ * `undefined` when the value is unusable (BAPI-898).
206
+ *
207
+ * Stateless and private: a boundary helper that accepts an unknown value plus
208
+ * the current epoch time and returns a derived string, never the raw input.
209
+ * Only a finite number is accepted. A value strictly greater than `1e12` is
210
+ * interpreted as milliseconds since epoch; everything else — including exactly
211
+ * `1e12` — is interpreted as seconds. A resolved timestamp outside ten years
212
+ * before or after `nowMs` is rejected as an implausible worker-reported value.
213
+ *
214
+ * The result is emitted EXCLUSIVELY through `new Date(timestampMs).toISOString()`
215
+ * so raw worker input can never enter telemetry or diagnostics.
216
+ */
217
+ function normalizeRateLimitResetAt(value, nowMs) {
218
+ if (typeof value !== "number" || !Number.isFinite(value))
219
+ return undefined;
220
+ const timestampMs = value > 1e12 ? value : value * 1000;
221
+ if (!Number.isFinite(timestampMs))
222
+ return undefined;
223
+ const now = new Date(nowMs);
224
+ const min = new Date(now);
225
+ min.setUTCFullYear(min.getUTCFullYear() - 10);
226
+ const max = new Date(now);
227
+ max.setUTCFullYear(max.getUTCFullYear() + 10);
228
+ if (timestampMs < min.getTime() || timestampMs > max.getTime())
229
+ return undefined;
230
+ return new Date(timestampMs).toISOString();
231
+ }
232
+ /**
233
+ * Rank a rate-limit status by severity for most-severe-wins retention
234
+ * (BAPI-898): `allowed` is least severe, `allowed_warning` is next, and every
235
+ * other status (`rejected`, `blocked`, or anything unrecognized) is most severe.
236
+ *
237
+ * Pinned independently of stream parsing so retention logic (BAPI-898 Step 2)
238
+ * can compare severities without re-deriving this mapping.
239
+ */
240
+ export function rateLimitSeverity(status) {
241
+ if (status === "allowed")
242
+ return 0;
243
+ if (status === "allowed_warning")
244
+ return 1;
245
+ return 2;
246
+ }
203
247
  /**
204
248
  * Recognize a QUALIFYING `rate_limit_event`, or `null` for everything else.
205
249
  *
@@ -231,18 +275,46 @@ function parseRateLimitEvent(obj) {
231
275
  const qualifies = utilization >= RATE_LIMIT_WARNING_UTILIZATION || status !== "allowed";
232
276
  if (!qualifies)
233
277
  return null;
234
- return { rate_limit_type: rateLimitType, status, utilization };
278
+ const advisory = { rate_limit_type: rateLimitType, status, utilization };
279
+ // Invalid or missing reset data never disqualifies an otherwise-qualifying
280
+ // advisory — an absent `resets_at` is neutral, not an error.
281
+ const resetsAt = normalizeRateLimitResetAt(record.resetsAt, Date.now());
282
+ if (resetsAt !== undefined)
283
+ advisory.resets_at = resetsAt;
284
+ return advisory;
235
285
  }
236
286
  /**
237
- * Render the operator-facing rate-limit line.
287
+ * Fixed consequence clause appended to every rate-limit diagnostic (BAPI-898).
288
+ * Stated exactly once per line so an operator reading it cold understands what
289
+ * happens next without cross-referencing the runbook.
290
+ */
291
+ const RATE_LIMIT_CONSEQUENCE_TEXT = "The executor keeps claiming: a worker that hits the ceiling fails fast and its " +
292
+ "job classifies as a worker failure under the normal retry budget.";
293
+ /** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
294
+ const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-conductor-v2-operator-runbook.md §8.";
295
+ /**
296
+ * Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
238
297
  *
239
298
  * Exported so the stderr text has ONE definition rather than being rebuilt at the
240
299
  * call site — the job runner emits it, and the tests pin it, from here.
300
+ *
301
+ * One calm, scannable line: the sanitized limit type, rounded utilization, and
302
+ * status; whether a reset time is known; the fixed executor consequence; and the
303
+ * runbook reference. Punctuation shape:
304
+ * `executor: worker reports … at … (…); <reset clause>. <consequence> <reference>`
305
+ *
306
+ * `resets_at` is rendered ONLY from the parser-derived field — never from raw
307
+ * worker input — and its absence is neutral operational information, not an
308
+ * error. A non-finite `utilization` reaching this formatter at runtime (outside
309
+ * the parser's own finite-utilization qualification contract) falls back to
310
+ * `100%` rather than rendering `NaN%` or `undefined%`.
241
311
  */
242
312
  export function formatWorkerRateLimitAdvisory(advisory) {
243
- const percent = Math.round(advisory.utilization * 100);
313
+ const percent = Number.isFinite(advisory.utilization) ? Math.round(advisory.utilization * 100) : 100;
314
+ const resetClause = advisory.resets_at !== undefined ? `; resets at ${advisory.resets_at}` : "; reset time not reported";
244
315
  return (`executor: worker reports ${advisory.rate_limit_type} rate limit at ` +
245
- `${percent}% (${advisory.status})`);
316
+ `${percent}% (${advisory.status})${resetClause}. ` +
317
+ `${RATE_LIMIT_CONSEQUENCE_TEXT} ${RATE_LIMIT_RUNBOOK_REFERENCE}`);
246
318
  }
247
319
  /**
248
320
  * Tolerantly parse one Claude stream-json line for advisory signals.
@@ -465,7 +537,12 @@ export function createObservationState(deps, options) {
465
537
  let exitCode;
466
538
  let attemptStartSha;
467
539
  let attemptEndSha;
468
- /** BAPI-828: the FIRST qualifying advisory wins; later ones never overwrite it. */
540
+ /**
541
+ * BAPI-828/BAPI-898: the MOST SEVERE qualifying advisory observed so far wins,
542
+ * ranked by {@link rateLimitSeverity}. An advisory at equal or lower severity —
543
+ * including a repeat of the same event or a warning observed after a rejection
544
+ * — leaves this unchanged.
545
+ */
469
546
  let rateLimit;
470
547
  /** Residual partial line carried across stdout chunks (BAPI-828). */
471
548
  let buffer = "";
@@ -475,15 +552,30 @@ export function createObservationState(deps, options) {
475
552
  const parsed = parseClaudeStreamJsonLine(line);
476
553
  if (parsed.phase_hint)
477
554
  advisory.phase_hint = parsed.phase_hint;
478
- if (parsed.rate_limit && rateLimit === undefined) {
479
- rateLimit = parsed.rate_limit;
480
- try {
481
- options.onWorkerRateLimitAdvisory?.(rateLimit);
555
+ if (parsed.rate_limit) {
556
+ const observedAdvisory = parsed.rate_limit;
557
+ const isFirstObservation = rateLimit === undefined;
558
+ // BAPI-898: strict severity comparison replaces the old "first advisory
559
+ // wins" rule. Equal or lower severity — a repeat event or a warning
560
+ // observed after a rejected/blocked status — leaves the stored advisory
561
+ // unchanged.
562
+ if (isFirstObservation ||
563
+ rateLimitSeverity(observedAdvisory.status) > rateLimitSeverity(rateLimit.status)) {
564
+ rateLimit = observedAdvisory;
482
565
  }
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.
566
+ // The callback preserves one stderr line per job: it fires ONLY for the
567
+ // first qualifying advisory, while a later severity escalation is still
568
+ // retained above for `observation.snapshot()` and the existing terminal
569
+ // telemetry path.
570
+ if (isFirstObservation) {
571
+ try {
572
+ options.onWorkerRateLimitAdvisory?.(observedAdvisory);
573
+ }
574
+ catch {
575
+ // The callback only logs. A throwing one must not propagate out of the
576
+ // stdout pump, where it would be swallowed as a stream error and
577
+ // silently disable observation for the rest of the run.
578
+ }
487
579
  }
488
580
  }
489
581
  };
@@ -42,6 +42,56 @@ export const SUSPEND_GAP_SLACK_MS = 60_000;
42
42
  export function formatSuspendGapDiagnostic(elapsedMs) {
43
43
  return `executor: host appears to have been suspended for ~${Math.round(elapsedMs / 1000)} s (poll gap)`;
44
44
  }
45
+ function normalizeDispatcherAvailability(value) {
46
+ if (value === "fresh")
47
+ return "fresh";
48
+ if (value === "stale" || value === "never_seen")
49
+ return "absent";
50
+ return "unknown";
51
+ }
52
+ /**
53
+ * The unavailable diagnostic. Exported so the message has ONE definition that a
54
+ * test can assert against rather than a copy that drifts.
55
+ *
56
+ * Calm on purpose, and it says what the executor is going to do next: this is
57
+ * not a failure of the executor, it keeps polling, and the fix is on the server
58
+ * side. An executor that exited here would turn a restartable dispatcher outage
59
+ * into a second outage.
60
+ */
61
+ export function formatDispatcherUnavailableDiagnostic(liveness) {
62
+ return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
63
+ `(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
64
+ "Check DISABLE_SCHEDULER and GET /automation/health; see " +
65
+ "docs/claude/epic-conductor-v2-operator-runbook.md. Continuing to poll.");
66
+ }
67
+ /** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
68
+ export function formatDispatcherRecoveredDiagnostic() {
69
+ return ("executor: reconciler/dispatcher heartbeat is fresh again; continuing to poll.");
70
+ }
71
+ /**
72
+ * How often the executor publishes PER-PROCESS liveness (BAPI-871).
73
+ *
74
+ * The server calls an executor stale at 300 s (`EXECUTOR_STALE_SECONDS`). 60 s is
75
+ * comfortably below that — four consecutive misses before anyone is alarmed —
76
+ * which keeps a transient network blip, a VPN reconnect, or one slow request from
77
+ * manufacturing an outage while still detecting a genuinely dead process within
78
+ * the server's own window.
79
+ *
80
+ * Deliberately INDEPENDENT of claim results and of any active job. An executor
81
+ * that is idle is exactly the executor whose liveness was previously invisible,
82
+ * so a cadence that paused while nothing was claimed would rebuild the blind spot
83
+ * this endpoint exists to remove.
84
+ */
85
+ export const PROCESS_HEARTBEAT_INTERVAL_MS = 60_000;
86
+ /** Delivery-failure diagnostic. Bounded, credential-free, stderr-only. */
87
+ export function formatProcessHeartbeatFailureDiagnostic() {
88
+ return ("executor: process-heartbeat delivery is failing; the server may report this " +
89
+ "executor as stale. Claiming and running jobs are unaffected.");
90
+ }
91
+ /** Emitted once when delivery starts working again. */
92
+ export function formatProcessHeartbeatRecoveredDiagnostic() {
93
+ return "executor: process-heartbeat delivery has recovered.";
94
+ }
45
95
  /**
46
96
  * Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
47
97
  * in continuous mode it does not return (the process is long-lived).
@@ -70,6 +120,50 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
70
120
  // the sole eligibility authority. Declared here, outside both loops below, so
71
121
  // it persists across poll cycles for the lifetime of this `runExecutor` call.
72
122
  let lastClaimedEpicRunId = null;
123
+ // BAPI-871 — the last normalized dispatcher availability OBSERVED on a
124
+ // successful claim response, retained for the lifetime of this `runExecutor`
125
+ // call. Declared here, outside both loops, so the suppression below spans poll
126
+ // cycles: an absent dispatcher must produce one diagnostic, not one per poll.
127
+ //
128
+ // `null` means "nothing EVALUABLE observed yet", which is what makes the FIRST
129
+ // absent observation announce itself rather than being treated as an unchanged
130
+ // state. Only `fresh` and `absent` are ever stored — see the transparency rule
131
+ // for `unknown` in the observer below.
132
+ //
133
+ // Read-only diagnostics, exactly like `lastClaimedEpicRunId` above: it never
134
+ // feeds claim eligibility, cadence, retries, dispatch, or exit.
135
+ let lastDispatcherAvailability = null;
136
+ /**
137
+ * Announce a dispatcher availability CHANGE, and only a change.
138
+ *
139
+ * Called after every successful claim response and before the runner branches
140
+ * on claimed-versus-no-job, because the no-job branch is precisely the one that
141
+ * used to say nothing at all.
142
+ */
143
+ const observeDispatcherLiveness = (liveness) => {
144
+ const availability = normalizeDispatcherAvailability(liveness);
145
+ // `unknown` is TRANSPARENT: it emits nothing and, just as importantly, does
146
+ // not overwrite what was last actually established. An operator who saw
147
+ // "the dispatcher may not be running", then a stretch of unevaluable polls,
148
+ // then a fresh heartbeat, must still be told it recovered — and swallowing
149
+ // that recovery because an unevaluable poll sat in between would leave the
150
+ // absence warning standing as the last word.
151
+ if (availability === "unknown")
152
+ return;
153
+ const previous = lastDispatcherAvailability;
154
+ lastDispatcherAvailability = availability;
155
+ if (availability === previous)
156
+ return;
157
+ if (availability === "absent") {
158
+ // stderr, never `console.log` — stdout is the MCP protocol channel.
159
+ deps.errorLog(formatDispatcherUnavailableDiagnostic(liveness));
160
+ }
161
+ else if (previous === "absent") {
162
+ // Only from `absent`. A first-ever `fresh` observation is the ordinary
163
+ // healthy start-up and deserves no announcement.
164
+ deps.errorLog(formatDispatcherRecoveredDiagnostic());
165
+ }
166
+ };
73
167
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
74
168
  // the claim loop below — that scope is the whole feature. A cache created inside
75
169
  // the loop would be discarded every cycle and re-probe exactly as before; a
@@ -144,6 +238,102 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
144
238
  shutdownWaiters.splice(index, 1);
145
239
  }
146
240
  };
241
+ // The heartbeat loop's OWN stop signal (BAPI-871). Deliberately separate from
242
+ // `shutdownRequested`: winding the loop down at the end of a `once` run must
243
+ // not flip the runner's shutdown flag, because `beginShutdown` is idempotent
244
+ // on that flag and a signal arriving late — after the loop ended but before the
245
+ // handlers are unsubscribed — must still reach the live workers.
246
+ let heartbeatStopRequested = false;
247
+ const heartbeatWaiters = [];
248
+ const stopProcessHeartbeats = () => {
249
+ heartbeatStopRequested = true;
250
+ for (const wake of heartbeatWaiters.splice(0, heartbeatWaiters.length))
251
+ wake();
252
+ };
253
+ /**
254
+ * Sleep one heartbeat interval, returning early on either stop condition.
255
+ *
256
+ * Races the interval against a real shutdown AND the loop's own wind-down, so
257
+ * process exit never waits out a full cadence.
258
+ */
259
+ const sleepUntilHeartbeatOrStop = async (ms) => {
260
+ if (heartbeatStopRequested || shutdownRequested)
261
+ return "stop";
262
+ let wake;
263
+ const interrupted = new Promise((resolve) => {
264
+ wake = resolve;
265
+ });
266
+ heartbeatWaiters.push(wake);
267
+ shutdownWaiters.push(wake);
268
+ try {
269
+ return await Promise.race([
270
+ deps.sleep(ms).then(() => "slept"),
271
+ interrupted.then(() => "stop"),
272
+ ]);
273
+ }
274
+ finally {
275
+ for (const list of [heartbeatWaiters, shutdownWaiters]) {
276
+ const index = list.indexOf(wake);
277
+ if (index !== -1)
278
+ list.splice(index, 1);
279
+ }
280
+ }
281
+ };
282
+ /**
283
+ * Publish per-process liveness on a fixed cadence until shutdown (BAPI-871).
284
+ *
285
+ * Runs CONCURRENTLY with the poll loop rather than inside it, because the poll
286
+ * loop's cadence is the operator's to configure and its cycle time varies with
287
+ * whatever work it picks up — neither of which should be able to change how
288
+ * often this process proves it is alive.
289
+ *
290
+ * Every failure mode is contained here: delivery is non-critical monitoring, so
291
+ * a failure is reported to stderr and the loop keeps going. It cannot fail a
292
+ * claim, fail a job, or end the run.
293
+ */
294
+ const runProcessHeartbeatLoop = async () => {
295
+ const publish = httpClient.processHeartbeat;
296
+ // A client without the capability (every object-literal test fake) simply
297
+ // does not heartbeat. Nothing else about the run changes.
298
+ if (typeof publish !== "function")
299
+ return;
300
+ // `repoName` FIRST so the client authenticates with the primary repo's key in
301
+ // multi-repo mode, then the remaining configured repos, de-duplicated.
302
+ const repoNames = [options.repoName, ...options.repos].filter((repo, index, all) => repo && all.indexOf(repo) === index);
303
+ const request = {
304
+ component: "executor",
305
+ instance_id: options.executorId,
306
+ repo_names: repoNames,
307
+ };
308
+ // Suppression state: one message per CHANGE of delivery state, so a long
309
+ // outage produces one line rather than one per minute.
310
+ let lastFailed = false;
311
+ for (;;) {
312
+ let outcome;
313
+ try {
314
+ outcome = await publish.call(httpClient, request);
315
+ }
316
+ catch {
317
+ // The client already contains its own errors; this is belt-and-braces so
318
+ // an unexpected throw can never escape into the run.
319
+ outcome = "failed";
320
+ }
321
+ if (outcome === "failed" && !lastFailed) {
322
+ deps.errorLog(formatProcessHeartbeatFailureDiagnostic());
323
+ }
324
+ else if (outcome === "delivered" && lastFailed) {
325
+ deps.errorLog(formatProcessHeartbeatRecoveredDiagnostic());
326
+ }
327
+ lastFailed = outcome === "failed";
328
+ if (heartbeatStopRequested || shutdownRequested)
329
+ return;
330
+ // Woken by the same waiter list a real shutdown fires, so process exit does
331
+ // not have to wait out a full interval.
332
+ if ((await sleepUntilHeartbeatOrStop(PROCESS_HEARTBEAT_INTERVAL_MS)) === "stop") {
333
+ return;
334
+ }
335
+ }
336
+ };
147
337
  /**
148
338
  * Annotate an abnormally long COMPLETED poll gap (BAPI-828).
149
339
  *
@@ -222,9 +412,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
222
412
  }
223
413
  }
224
414
  }
415
+ // Started after the FIRST successful preflight and joined before the runner
416
+ // returns. Declared here so the `finally` below can await it.
417
+ let processHeartbeats = null;
225
418
  try {
226
419
  for (;;) {
227
420
  const report = await collectPreflight(options, deps, preflightSeams);
421
+ // First heartbeat goes out immediately once preflight passes, so a freshly
422
+ // started executor is visible to the server without waiting one interval.
423
+ // Gated on `report.ok` because an executor that preflight refuses is not
424
+ // going to do any work, and advertising it as live would be a lie.
425
+ if (report.ok && processHeartbeats === null && !shutdownRequested) {
426
+ processHeartbeats = runProcessHeartbeatLoop();
427
+ }
228
428
  // BAPI-727: preflight warnings were previously collected but never emitted, so
229
429
  // a non-fatal finding — an overridden MCP-shadowing collision, an unreadable
230
430
  // ~/.claude.json — was invisible to the operator. Emit them before the claim
@@ -248,6 +448,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
248
448
  const freeSlots = options.maxConcurrent - active.size;
249
449
  const manifest = buildClaimManifest(report, options, freeSlots);
250
450
  const result = await httpClient.claim(manifest);
451
+ // Evaluated on every SUCCESSFUL claim response — 200 and 204 alike —
452
+ // ahead of the branch below. An error result carries no verdict and is
453
+ // not evidence about the dispatcher, so it is deliberately not observed.
454
+ if (result.kind === "claimed" || result.kind === "none") {
455
+ observeDispatcherLiveness(result.reconcilerLiveness);
456
+ }
251
457
  if (result.kind === "claimed") {
252
458
  const currentEpicRunId = result.job.epic_run_id ?? null;
253
459
  if (lastClaimedEpicRunId !== null &&
@@ -304,6 +510,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
304
510
  await Promise.all(active.values());
305
511
  }
306
512
  finally {
513
+ // Join the heartbeat loop before returning, via its OWN stop signal. A `once`
514
+ // run breaks out of the poll loop with `shutdownRequested` still false, and
515
+ // it must stay false: `beginShutdown` is idempotent on that flag, so setting
516
+ // it here would silently swallow a signal that arrives during this wind-down.
517
+ if (processHeartbeats !== null) {
518
+ stopProcessHeartbeats();
519
+ try {
520
+ await processHeartbeats;
521
+ }
522
+ catch {
523
+ /* a monitoring loop must never change the runner's outcome */
524
+ }
525
+ }
307
526
  // Unsubscribe in a runner-level `finally`, so a direct or repeated
308
527
  // `runExecutor` call — an embedded runner, a `--once` invocation in a loop, a
309
528
  // test suite — cannot leave stale listeners bound to a registry that has since