@bridge_gpt/mcp-server 0.2.42 → 0.2.44
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 +321 -182
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/commands.generated.js +4 -3
- package/build/conduct-epic/cli.js +195 -0
- package/build/conductor/bridge-api-client.js +121 -0
- package/build/conductor/cli.js +63 -0
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor-bin.js +9 -5
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +13 -3
- package/build/drive-epic.js +375 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-runner.js +50 -3
- package/build/executor/observation.js +105 -13
- package/build/executor/runner.js +219 -0
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index.js +2156 -98
- package/build/install-bridge.js +23 -9
- package/build/pipelines.generated.js +304 -14
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +14 -4
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +19 -0
- package/build/plane/shutdown.js +71 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +75 -5
- package/build/setup-epic.js +82 -8
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/package.json +1 -1
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
};
|
package/build/executor/runner.js
CHANGED
|
@@ -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
|