@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.
@@ -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
- * BAPI-828: the runner-scoped control object, carrying the live worker
586
- * registry this job's worker registers with.
631
+ * Run ONE integration-correction worker under a spawn-job driver (BAPI-1259).
587
632
  *
588
- * A SEVENTH positional parameter, appended after the existing seams argument so
589
- * every current caller — the runner's default `runJob`, the many tests that
590
- * invoke this directly with four to six arguments — keeps compiling unchanged.
591
- * Absent means no executor-level shutdown control, which is exactly right for a
592
- * caller that has no executor loop around it.
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 = "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.";
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 — proving the advisory
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
- consumeAdvisoryLine(buffer.slice(0, newlineAt));
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
- consumeAdvisoryLine(buffer);
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
  }
@@ -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
  }
@@ -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
+ ];