@bridge_gpt/mcp-server 0.2.60 → 0.2.62
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 +16 -8
- package/build/commands.generated.js +3 -3
- package/build/conductor/bridge-api-client.js +353 -2
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +99 -16
- package/build/conductor/readiness.js +108 -15
- package/build/credential-store.js +156 -0
- package/build/doctor.js +94 -6
- package/build/drive-epic.js +124 -12
- package/build/epic-implementer-bin.js +13 -13
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/cli.js +9 -0
- package/build/executor/credentials.js +17 -1
- package/build/executor/http-client.js +45 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +92 -9
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/types.js +43 -0
- package/build/index.js +338 -114
- package/build/install-bridge-conductor.js +398 -6
- package/build/install-bridge.js +92 -16
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +1 -1
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +103 -32
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -50,6 +50,7 @@ import { ensureExecutorWorktree } from "./worktree.js";
|
|
|
50
50
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
51
51
|
import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
52
52
|
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
|
|
53
|
+
import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
|
|
53
54
|
import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
|
|
54
55
|
/**
|
|
55
56
|
* Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
|
|
@@ -84,6 +85,52 @@ function classificationToErrorKind(c) {
|
|
|
84
85
|
return "WorkerCrashed";
|
|
85
86
|
}
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* BAPI-1258 — build terminal capacity evidence for a FAILED worker, or
|
|
90
|
+
* `undefined` when the evidence is not usable.
|
|
91
|
+
*
|
|
92
|
+
* Usable only when all of these hold: the claim advertised a capability this
|
|
93
|
+
* executor recognizes; that capability's runtime is the agent that actually ran
|
|
94
|
+
* the job (the runtime check is by agent id, so this module stays agent-neutral);
|
|
95
|
+
* and the observation state holds exactly one non-contradictory rejection. The
|
|
96
|
+
* job id and attempt are the CLAIM's, never anything read from worker output.
|
|
97
|
+
* The caller decides precedence: this is never consulted for a successful
|
|
98
|
+
* process, an authentication refusal, or an integrity failure.
|
|
99
|
+
*/
|
|
100
|
+
export function buildTerminalCapacityEvidence(job, observation, runningAgentId) {
|
|
101
|
+
const capability = job.terminal_capacity_capability;
|
|
102
|
+
if (!capability || typeof capability !== "object")
|
|
103
|
+
return undefined;
|
|
104
|
+
if (!TERMINAL_CAPACITY_PROTOCOL_VERSIONS.includes(capability.protocol_version)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (!TERMINAL_CAPACITY_RUNTIMES.includes(capability.runtime))
|
|
108
|
+
return undefined;
|
|
109
|
+
if (!TERMINAL_CAPACITY_RUNTIME_CONTRACTS.includes(capability.runtime_contract)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
if (capability.runtime !== runningAgentId)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (!Number.isInteger(job.id) || job.id < 1)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (!Number.isInteger(job.attempt_count) || job.attempt_count < 1)
|
|
117
|
+
return undefined;
|
|
118
|
+
const observed = observation.terminalCapacity();
|
|
119
|
+
if (observed === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
const evidence = {
|
|
122
|
+
protocol_version: capability.protocol_version,
|
|
123
|
+
runtime: capability.runtime,
|
|
124
|
+
runtime_contract: capability.runtime_contract,
|
|
125
|
+
job_id: job.id,
|
|
126
|
+
attempt: job.attempt_count,
|
|
127
|
+
cause: observed.cause,
|
|
128
|
+
observed_at: observed.observed_at,
|
|
129
|
+
};
|
|
130
|
+
if (observed.retry_not_before !== undefined)
|
|
131
|
+
evidence.retry_not_before = observed.retry_not_before;
|
|
132
|
+
return evidence;
|
|
133
|
+
}
|
|
87
134
|
function boundedError(err) {
|
|
88
135
|
const message = err instanceof Error ? err.message : String(err);
|
|
89
136
|
return message.slice(0, 200);
|
|
@@ -580,18 +627,28 @@ async function sendTerminalMutationWithAuthReport(params, job, control) {
|
|
|
580
627
|
return terminal;
|
|
581
628
|
}
|
|
582
629
|
/** Dispatch and run a single claimed job. */
|
|
583
|
-
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
584
630
|
/**
|
|
585
|
-
*
|
|
586
|
-
* registry this job's worker registers with.
|
|
631
|
+
* Run ONE integration-correction worker under a spawn-job driver (BAPI-1259).
|
|
587
632
|
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
633
|
+
* The same locked spawn path `reconcile_overlap` uses (BAPI-1152): the worktree
|
|
634
|
+
* lock, deny layer, MCP containment, env-file strip, worker log, and the
|
|
635
|
+
* heartbeat/timeout supervision all apply unchanged, and there is still exactly
|
|
636
|
+
* one worker spawn boundary. The integration action's driver prepares the epic
|
|
637
|
+
* branch before the worker, and after it owns the commit and the single
|
|
638
|
+
* authorized push; the worker is launched WITHOUT a worker branch, so the
|
|
639
|
+
* deterministic guard denies every push it attempts, and it gets the usual
|
|
640
|
+
* worker environment — no operational database, no write credential.
|
|
641
|
+
*
|
|
642
|
+
* `job` is the driver's synthetic recovery job and `httpClient` an adapter that
|
|
643
|
+
* maps heartbeats onto the integration action's lease; neither reaches
|
|
644
|
+
* `/executor/jobs`. Integration actions are NOT ordinary executor jobs.
|
|
593
645
|
*/
|
|
594
|
-
control) {
|
|
646
|
+
export async function runIntegrationCorrectionSpawn(job, httpClient, options, deps, driver, seams = {}, control) {
|
|
647
|
+
const { ownership, observation } = createJobRunState(options, deps);
|
|
648
|
+
return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
|
|
649
|
+
}
|
|
650
|
+
/** The per-job ownership and observation state every job runner starts from. */
|
|
651
|
+
function createJobRunState(options, deps) {
|
|
595
652
|
const ownership = {
|
|
596
653
|
abandoned: false,
|
|
597
654
|
lastSuccessfulHeartbeatAt: deps.now(),
|
|
@@ -607,6 +664,21 @@ control) {
|
|
|
607
664
|
deps.errorLog(formatWorkerRateLimitAdvisory(advisory));
|
|
608
665
|
},
|
|
609
666
|
});
|
|
667
|
+
return { ownership, observation };
|
|
668
|
+
}
|
|
669
|
+
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
670
|
+
/**
|
|
671
|
+
* BAPI-828: the runner-scoped control object, carrying the live worker
|
|
672
|
+
* registry this job's worker registers with.
|
|
673
|
+
*
|
|
674
|
+
* A SEVENTH positional parameter, appended after the existing seams argument so
|
|
675
|
+
* every current caller — the runner's default `runJob`, the many tests that
|
|
676
|
+
* invoke this directly with four to six arguments — keeps compiling unchanged.
|
|
677
|
+
* Absent means no executor-level shutdown control, which is exactly right for a
|
|
678
|
+
* caller that has no executor loop around it.
|
|
679
|
+
*/
|
|
680
|
+
control) {
|
|
681
|
+
const { ownership, observation } = createJobRunState(options, deps);
|
|
610
682
|
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
611
683
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
612
684
|
// worker (TDD §5/§9).
|
|
@@ -2422,6 +2494,16 @@ async function runPreparedSpawn(params) {
|
|
|
2422
2494
|
const authDetection = adapter.authFailureDetection;
|
|
2423
2495
|
const authFailure = authDetection?.supported === true &&
|
|
2424
2496
|
authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
|
|
2497
|
+
// BAPI-1258: capacity evidence is attached only AFTER authentication has been
|
|
2498
|
+
// ruled out (an auth refusal keeps its own terminal kind and never waits), and
|
|
2499
|
+
// only on this failed-process branch — a clean exit completes normally even if
|
|
2500
|
+
// the worker saw a rejection on the way. The ordinary error kind, message,
|
|
2501
|
+
// classification, commit residue, and attempt SHAs are sent unchanged; the
|
|
2502
|
+
// evidence rides beside them, so a server refusal can re-send the identical
|
|
2503
|
+
// ordinary failure.
|
|
2504
|
+
const capacityEvidence = authFailure
|
|
2505
|
+
? undefined
|
|
2506
|
+
: buildTerminalCapacityEvidence(job, observation, adapter.identity.agentId);
|
|
2425
2507
|
const terminal = await sendTerminalMutationWithAuthReport({
|
|
2426
2508
|
kind: "fail",
|
|
2427
2509
|
send: () => httpClient.fail(job, {
|
|
@@ -2432,6 +2514,7 @@ async function runPreparedSpawn(params) {
|
|
|
2432
2514
|
classification: toWireClassification(procResult.classification),
|
|
2433
2515
|
...commitResidue(git),
|
|
2434
2516
|
telemetry: observation.snapshot(),
|
|
2517
|
+
...(capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}),
|
|
2435
2518
|
}),
|
|
2436
2519
|
deps,
|
|
2437
2520
|
options,
|
|
@@ -288,8 +288,10 @@ function parseRateLimitEvent(obj) {
|
|
|
288
288
|
* Stated exactly once per line so an operator reading it cold understands what
|
|
289
289
|
* happens next without cross-referencing the runbook.
|
|
290
290
|
*/
|
|
291
|
-
const RATE_LIMIT_CONSEQUENCE_TEXT = "
|
|
292
|
-
"
|
|
291
|
+
const RATE_LIMIT_CONSEQUENCE_TEXT = "This warning is advisory only and triggers no waiting. Automatic capacity " +
|
|
292
|
+
"waiting happens only when the worker then fails with the supported terminal " +
|
|
293
|
+
"rejection and the server advertised capacity recovery for this job; otherwise " +
|
|
294
|
+
"the failure follows ordinary failure handling.";
|
|
293
295
|
/** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
|
|
294
296
|
const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-implementer-v2-operator-runbook.md §8.";
|
|
295
297
|
/**
|
|
@@ -354,6 +356,67 @@ export function parseClaudeStreamJsonLine(line) {
|
|
|
354
356
|
function pickString(value) {
|
|
355
357
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
356
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* The exact status that marks a terminal rejection. `allowed_warning`,
|
|
361
|
+
* `blocked`, and any unknown status are NOT terminal capacity: the first is a
|
|
362
|
+
* warning, and the others are not part of the supported contract.
|
|
363
|
+
*/
|
|
364
|
+
const TERMINAL_CAPACITY_REJECTED_STATUS = "rejected";
|
|
365
|
+
/** Closed shape for a `rateLimitType` token the terminal parser will accept. */
|
|
366
|
+
const TERMINAL_CAPACITY_LIMIT_TYPE_PATTERN = /^[a-z0-9_]{1,64}$/;
|
|
367
|
+
/**
|
|
368
|
+
* Map the provider's limit window to a normalized cause. Long windows (the
|
|
369
|
+
* weekly ceilings) are account quota; shorter windows are rate limits.
|
|
370
|
+
*/
|
|
371
|
+
function terminalCapacityCauseFor(rateLimitType) {
|
|
372
|
+
return rateLimitType.startsWith("seven_day") ? "quota_exhausted" : "rate_limit_exhausted";
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Parse ONE stream-json line for the supported terminal-capacity event, or
|
|
376
|
+
* `undefined` for anything else (BAPI-1258).
|
|
377
|
+
*
|
|
378
|
+
* Accepts only `{"type":"rate_limit_event","rate_limit_info":{"status":"rejected",
|
|
379
|
+
* "rateLimitType":<closed token>, "resetsAt"?:<number>}}`. Unknown event types,
|
|
380
|
+
* unknown statuses, malformed tokens, and malformed reset values all yield
|
|
381
|
+
* `undefined` (a bad reset drops only the reset, not the rejection). Never
|
|
382
|
+
* throws: a failure here only means "no confirmed evidence".
|
|
383
|
+
*/
|
|
384
|
+
export function parseClaudeTerminalCapacityLine(line, nowMs) {
|
|
385
|
+
try {
|
|
386
|
+
const trimmed = typeof line === "string" ? line.trim() : "";
|
|
387
|
+
if (trimmed.length === 0)
|
|
388
|
+
return undefined;
|
|
389
|
+
const parsed = JSON.parse(trimmed);
|
|
390
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
391
|
+
return undefined;
|
|
392
|
+
const obj = parsed;
|
|
393
|
+
if (obj.type !== "rate_limit_event")
|
|
394
|
+
return undefined;
|
|
395
|
+
const info = obj.rate_limit_info;
|
|
396
|
+
if (!info || typeof info !== "object" || Array.isArray(info))
|
|
397
|
+
return undefined;
|
|
398
|
+
const record = info;
|
|
399
|
+
if (record.status !== TERMINAL_CAPACITY_REJECTED_STATUS)
|
|
400
|
+
return undefined;
|
|
401
|
+
const rateLimitType = record.rateLimitType;
|
|
402
|
+
if (typeof rateLimitType !== "string" || !TERMINAL_CAPACITY_LIMIT_TYPE_PATTERN.test(rateLimitType)) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
if (!Number.isFinite(nowMs))
|
|
406
|
+
return undefined;
|
|
407
|
+
const observation = {
|
|
408
|
+
cause: terminalCapacityCauseFor(rateLimitType),
|
|
409
|
+
observed_at: new Date(nowMs).toISOString(),
|
|
410
|
+
};
|
|
411
|
+
const resetAt = normalizeRateLimitResetAt(record.resetsAt, nowMs);
|
|
412
|
+
if (resetAt !== undefined)
|
|
413
|
+
observation.retry_not_before = resetAt;
|
|
414
|
+
return observation;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
357
420
|
// ---------------------------------------------------------------------------
|
|
358
421
|
// MCP surface assertion (BAPI-790)
|
|
359
422
|
// ---------------------------------------------------------------------------
|
|
@@ -528,8 +591,11 @@ export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
|
|
|
528
591
|
export const MAX_BUFFERED_ADVISORY_LINE_CHARS = 1_048_576;
|
|
529
592
|
/**
|
|
530
593
|
* Create the observation state. When `advisoryParserEnabled` is false, stdout is
|
|
531
|
-
* still timestamped (`last_stdout_at`) but never parsed
|
|
532
|
-
* parser is not load-bearing.
|
|
594
|
+
* still timestamped (`last_stdout_at`) but never parsed for advisory hints —
|
|
595
|
+
* proving the advisory parser is not load-bearing. The separate BAPI-1258
|
|
596
|
+
* terminal-capacity parser still reads lines unless
|
|
597
|
+
* `terminalCapacityParserEnabled` is `false`; it only ever yields "no evidence"
|
|
598
|
+
* on bad input, so ordinary success never depends on it either.
|
|
533
599
|
*/
|
|
534
600
|
export function createObservationState(deps, options) {
|
|
535
601
|
let git = {};
|
|
@@ -544,6 +610,15 @@ export function createObservationState(deps, options) {
|
|
|
544
610
|
* — leaves this unchanged.
|
|
545
611
|
*/
|
|
546
612
|
let rateLimit;
|
|
613
|
+
/**
|
|
614
|
+
* BAPI-1258: the terminal-capacity channel. The FIRST rejection fixes the
|
|
615
|
+
* cause and `observed_at`; a repeat of the same cause may only carry a later
|
|
616
|
+
* reset forward. A rejection with a DIFFERENT cause makes the evidence
|
|
617
|
+
* contradictory, and contradictory evidence is never reported.
|
|
618
|
+
*/
|
|
619
|
+
let terminalCapacity;
|
|
620
|
+
let terminalCapacityContradictory = false;
|
|
621
|
+
const terminalCapacityParserEnabled = options.terminalCapacityParserEnabled !== false;
|
|
547
622
|
/** Residual partial line carried across stdout chunks (BAPI-828). */
|
|
548
623
|
let buffer = "";
|
|
549
624
|
/** True while skipping the remainder of a line that blew the size cap. */
|
|
@@ -579,10 +654,37 @@ export function createObservationState(deps, options) {
|
|
|
579
654
|
}
|
|
580
655
|
}
|
|
581
656
|
};
|
|
657
|
+
const consumeTerminalCapacityLine = (line) => {
|
|
658
|
+
if (terminalCapacityContradictory)
|
|
659
|
+
return;
|
|
660
|
+
const observed = parseClaudeTerminalCapacityLine(line, deps.now());
|
|
661
|
+
if (observed === undefined)
|
|
662
|
+
return;
|
|
663
|
+
if (terminalCapacity === undefined) {
|
|
664
|
+
terminalCapacity = observed;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (terminalCapacity.cause !== observed.cause) {
|
|
668
|
+
terminalCapacityContradictory = true;
|
|
669
|
+
terminalCapacity = undefined;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (observed.retry_not_before !== undefined &&
|
|
673
|
+
(terminalCapacity.retry_not_before === undefined ||
|
|
674
|
+
observed.retry_not_before > terminalCapacity.retry_not_before)) {
|
|
675
|
+
terminalCapacity = { ...terminalCapacity, retry_not_before: observed.retry_not_before };
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
const consumeLine = (line) => {
|
|
679
|
+
if (options.advisoryParserEnabled)
|
|
680
|
+
consumeAdvisoryLine(line);
|
|
681
|
+
if (terminalCapacityParserEnabled)
|
|
682
|
+
consumeTerminalCapacityLine(line);
|
|
683
|
+
};
|
|
582
684
|
return {
|
|
583
685
|
recordStdout(chunk) {
|
|
584
686
|
advisory.last_stdout_at = new Date(deps.now()).toISOString();
|
|
585
|
-
if (!options.advisoryParserEnabled)
|
|
687
|
+
if (!options.advisoryParserEnabled && !terminalCapacityParserEnabled)
|
|
586
688
|
return;
|
|
587
689
|
if (typeof chunk !== "string" || chunk.length === 0)
|
|
588
690
|
return;
|
|
@@ -605,7 +707,7 @@ export function createObservationState(deps, options) {
|
|
|
605
707
|
buffer += input;
|
|
606
708
|
let newlineAt = buffer.indexOf("\n");
|
|
607
709
|
while (newlineAt !== -1) {
|
|
608
|
-
|
|
710
|
+
consumeLine(buffer.slice(0, newlineAt));
|
|
609
711
|
buffer = buffer.slice(newlineAt + 1);
|
|
610
712
|
newlineAt = buffer.indexOf("\n");
|
|
611
713
|
}
|
|
@@ -625,7 +727,7 @@ export function createObservationState(deps, options) {
|
|
|
625
727
|
// is harmless: a truncated line does not parse, and the first-wins guard
|
|
626
728
|
// above makes a repeat parse of a complete line a no-op.
|
|
627
729
|
if (buffer.length > 0)
|
|
628
|
-
|
|
730
|
+
consumeLine(buffer);
|
|
629
731
|
},
|
|
630
732
|
setGitTelemetry(telemetry) {
|
|
631
733
|
git = telemetry;
|
|
@@ -667,5 +769,10 @@ export function createObservationState(deps, options) {
|
|
|
667
769
|
residue.attempt_end_sha = attemptEndSha;
|
|
668
770
|
return residue;
|
|
669
771
|
},
|
|
772
|
+
terminalCapacity() {
|
|
773
|
+
if (terminalCapacityContradictory || terminalCapacity === undefined)
|
|
774
|
+
return undefined;
|
|
775
|
+
return { ...terminalCapacity };
|
|
776
|
+
},
|
|
670
777
|
};
|
|
671
778
|
}
|
package/build/executor/runner.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* after all active jobs finish. Depends on NO v1 conductor event/ledger module.
|
|
8
8
|
*/
|
|
9
9
|
import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
|
|
10
|
-
import { runClaimedJob } from "./job-runner.js";
|
|
10
|
+
import { runClaimedJob, runIntegrationCorrectionSpawn } from "./job-runner.js";
|
|
11
11
|
import { appendToActiveJobLogs } from "./job-log-registry.js";
|
|
12
12
|
import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
|
|
13
13
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
14
|
+
import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
|
|
15
|
+
import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
|
|
14
16
|
import { validateBranchName } from "../base-ref.js";
|
|
15
17
|
import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
16
18
|
/**
|
|
@@ -94,6 +96,13 @@ export function formatProcessHeartbeatFailureDiagnostic() {
|
|
|
94
96
|
export function formatProcessHeartbeatRecoveredDiagnostic() {
|
|
95
97
|
return "executor: process-heartbeat delivery has recovered.";
|
|
96
98
|
}
|
|
99
|
+
/** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
|
|
100
|
+
function correctionTimeoutSeconds(deadlineAt, nowMs) {
|
|
101
|
+
const deadline = Date.parse(deadlineAt);
|
|
102
|
+
if (!Number.isFinite(deadline))
|
|
103
|
+
return 60;
|
|
104
|
+
return Math.max(60, Math.floor((deadline - nowMs) / 1000) - 60);
|
|
105
|
+
}
|
|
97
106
|
/**
|
|
98
107
|
* Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
|
|
99
108
|
* in continuous mode it does not return (the process is long-lived).
|
|
@@ -113,6 +122,13 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
113
122
|
platform: deps.platform,
|
|
114
123
|
}));
|
|
115
124
|
const active = new Map();
|
|
125
|
+
// BAPI-1259 — integration actions share `active` (and therefore the same
|
|
126
|
+
// capacity budget and shutdown drain) with ordinary jobs, keyed by negative
|
|
127
|
+
// numbers so they can never collide with a server job id.
|
|
128
|
+
let integrationSeq = 0;
|
|
129
|
+
// Set once when the server says it has no integration-action route (an older
|
|
130
|
+
// deployment), so the executor stops asking rather than logging every poll.
|
|
131
|
+
let integrationActionsUnsupported = false;
|
|
116
132
|
// BAPI-794 — process-local cross-run claim observability. Retains only the
|
|
117
133
|
// last successfully claimed job's `epic_run_id` (never job payloads,
|
|
118
134
|
// credentials, or arguments) so an unintended repo-wide move between epic
|
|
@@ -536,6 +552,85 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
536
552
|
})();
|
|
537
553
|
active.set(job.id, promise);
|
|
538
554
|
}
|
|
555
|
+
const runOneIntegrationAction = seams.runIntegrationAction ??
|
|
556
|
+
((action) => runIntegrationAction(action, seams.integrationActions, {
|
|
557
|
+
env: deps.env,
|
|
558
|
+
runCommand: deps.runCommand,
|
|
559
|
+
sleep: deps.sleep,
|
|
560
|
+
errorLog: deps.errorLog,
|
|
561
|
+
cwd: deps.cwd,
|
|
562
|
+
}, {
|
|
563
|
+
// A correction reuses the executor's ONE locked spawn path: the same
|
|
564
|
+
// worktree lock, containment, and supervision every worker gets.
|
|
565
|
+
runCorrection: async (claimed, driver, heartbeat) => {
|
|
566
|
+
const adapter = createIntegrationCorrectionLeaseAdapter(heartbeat);
|
|
567
|
+
const job = buildIntegrationCorrectionJob(claimed, correctionTimeoutSeconds(claimed.deadline_at, deps.now()));
|
|
568
|
+
await runIntegrationCorrectionSpawn(job, adapter.client, options, deps, driver, undefined, control);
|
|
569
|
+
return adapter.captured();
|
|
570
|
+
},
|
|
571
|
+
}));
|
|
572
|
+
function dispatchIntegrationAction(action) {
|
|
573
|
+
const key = --integrationSeq;
|
|
574
|
+
const promise = (async () => {
|
|
575
|
+
try {
|
|
576
|
+
await runOneIntegrationAction(action);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
580
|
+
deps.errorLog(`executor integration action crashed: ${message.slice(0, 200)}`);
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
active.delete(key);
|
|
584
|
+
}
|
|
585
|
+
})();
|
|
586
|
+
active.set(key, promise);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Claim durable integration actions into whatever capacity is left
|
|
590
|
+
* (BAPI-1259). The claim scope is the executor's own: `--epic-run-id` runs,
|
|
591
|
+
* or the whole repository for `--repo-wide`. The action driver chooses no
|
|
592
|
+
* next ticket and no gate — it performs one action and reports facts.
|
|
593
|
+
*/
|
|
594
|
+
async function claimIntegrationActions() {
|
|
595
|
+
const client = seams.integrationActions;
|
|
596
|
+
if (!client || integrationActionsUnsupported)
|
|
597
|
+
return;
|
|
598
|
+
for (const repoName of options.repos) {
|
|
599
|
+
while (!shutdownRequested && options.maxConcurrent - active.size > 0) {
|
|
600
|
+
const result = await client.claim({
|
|
601
|
+
protocol_version: INTEGRATION_ACTION_PROTOCOL_VERSION,
|
|
602
|
+
repo_name: repoName,
|
|
603
|
+
executor_id: options.executorId,
|
|
604
|
+
...(options.epicRunIds !== undefined
|
|
605
|
+
? { epic_run_ids: options.epicRunIds }
|
|
606
|
+
: { repo_wide: true }),
|
|
607
|
+
});
|
|
608
|
+
if (result.kind === "claimed") {
|
|
609
|
+
dispatchIntegrationAction(result.action);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (result.kind === "fatal") {
|
|
613
|
+
if (result.authHttpStatus === 401 || result.authHttpStatus === 403) {
|
|
614
|
+
control.reportAuthFatal?.({
|
|
615
|
+
repoName,
|
|
616
|
+
category: "claim",
|
|
617
|
+
httpStatus: result.authHttpStatus,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
integrationActionsUnsupported = true;
|
|
622
|
+
deps.errorLog(`executor: integration actions unavailable on this server (${result.error}); ` +
|
|
623
|
+
"not claiming them again in this process");
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (result.kind === "retryable") {
|
|
628
|
+
deps.errorLog(`executor integration action claim retryable: ${result.error}`);
|
|
629
|
+
}
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
539
634
|
// Handlers go on BEFORE preflight and before the first claim (BAPI-828).
|
|
540
635
|
// Preflight can take seconds and the first claim can spawn a worker immediately
|
|
541
636
|
// after it, so registering any later would leave a real window in which a
|
|
@@ -654,6 +749,11 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
654
749
|
// cycle's claim loop (a claim-time auth-fatal is the case that matters —
|
|
655
750
|
// it can flip `shutdownRequested` while `active.size` is still 0). No
|
|
656
751
|
// worktree-mutating sweep may run once the auth-fatal flag is set.
|
|
752
|
+
// BAPI-1259 — integration actions take whatever capacity ordinary jobs
|
|
753
|
+
// left this cycle, under the same shutdown gate.
|
|
754
|
+
if (!shutdownRequested) {
|
|
755
|
+
await claimIntegrationActions();
|
|
756
|
+
}
|
|
657
757
|
if (!shutdownRequested) {
|
|
658
758
|
await maybeSweepWorktrees();
|
|
659
759
|
}
|
package/build/executor/types.js
CHANGED
|
@@ -42,6 +42,24 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
|
|
|
42
42
|
"no_progress",
|
|
43
43
|
"lease_expired",
|
|
44
44
|
];
|
|
45
|
+
/**
|
|
46
|
+
* BAPI-1258 — the terminal provider-capacity protocol contract, mirroring
|
|
47
|
+
* `src/python/llms/executor_capacity_contract.py`. Closed values: an executor
|
|
48
|
+
* or server that does not recognize a value refuses it instead of guessing.
|
|
49
|
+
*
|
|
50
|
+
* The supported runtime contract is the packaged Claude runtime's stream-json
|
|
51
|
+
* `rate_limit_event` whose `rate_limit_info.status` is exactly `"rejected"`,
|
|
52
|
+
* observed during a job whose process then failed. The repository pins no
|
|
53
|
+
* Claude Code release, so the contract is named for the event shape it relies on.
|
|
54
|
+
*/
|
|
55
|
+
export const TERMINAL_CAPACITY_PROTOCOL_VERSIONS = [1];
|
|
56
|
+
export const TERMINAL_CAPACITY_RUNTIMES = ["claude"];
|
|
57
|
+
export const TERMINAL_CAPACITY_RUNTIME_CONTRACTS = ["claude-stream-json.rate-limit-rejected.v1"];
|
|
58
|
+
export const TERMINAL_CAPACITY_CAUSE_VALUES = [
|
|
59
|
+
"rate_limit_exhausted",
|
|
60
|
+
"quota_exhausted",
|
|
61
|
+
"provider_capacity_unavailable",
|
|
62
|
+
];
|
|
45
63
|
/**
|
|
46
64
|
* Outcome of a heartbeat/complete/fail mutation (T2 status mapping):
|
|
47
65
|
* - `updated` 2xx `{ ok: true }`
|
|
@@ -95,3 +113,28 @@ export const RECONCILER_LIVENESS_VALUES = ["fresh", "stale", "never_seen", "unkn
|
|
|
95
113
|
export function normalizeHeartbeatResult(response) {
|
|
96
114
|
return typeof response === "string" ? { outcome: response } : response;
|
|
97
115
|
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Integration actions (BAPI-1259)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// A SEPARATELY DISCRIMINATED run-level protocol, mirrored from
|
|
121
|
+
// `api/models/epic_integration_action.py`. It is deliberately NOT a member of
|
|
122
|
+
// `EXECUTOR_JOB_TYPE_VALUES` above: an ordinary claim never returns one, and an
|
|
123
|
+
// integration action has no ticket. The executor consumes it through its own
|
|
124
|
+
// client and its own claim loop step.
|
|
125
|
+
/** The one protocol version this executor speaks (exact equality server-side). */
|
|
126
|
+
export const INTEGRATION_ACTION_PROTOCOL_VERSION = 1;
|
|
127
|
+
/** The closed operation vocabulary. There is no merge operation. */
|
|
128
|
+
export const INTEGRATION_ACTION_OPERATIONS = ["sync_base", "correct", "mark_ready"];
|
|
129
|
+
/** The closed fact vocabulary a report may carry. */
|
|
130
|
+
export const INTEGRATION_ACTION_OUTCOMES = [
|
|
131
|
+
"applied",
|
|
132
|
+
"already_satisfied",
|
|
133
|
+
"conflict",
|
|
134
|
+
"authority_unavailable",
|
|
135
|
+
"authority_lost",
|
|
136
|
+
"head_drift",
|
|
137
|
+
"pr_ambiguous",
|
|
138
|
+
"indeterminate",
|
|
139
|
+
"failed",
|
|
140
|
+
];
|