@bridge_gpt/mcp-server 0.2.59 → 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.
Files changed (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -19,6 +19,7 @@ import os from "node:os";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
20
  import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
21
21
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
+ import { createProductionCiRerunDeps, runCiRerunJob, } from "./ci-rerun-job.js";
22
23
  import { createObservationState } from "./observation.js";
23
24
  import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
24
25
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
@@ -49,6 +50,7 @@ import { ensureExecutorWorktree } from "./worktree.js";
49
50
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
50
51
  import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
51
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";
52
54
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
53
55
  /**
54
56
  * Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
@@ -83,6 +85,52 @@ function classificationToErrorKind(c) {
83
85
  return "WorkerCrashed";
84
86
  }
85
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
+ }
86
134
  function boundedError(err) {
87
135
  const message = err instanceof Error ? err.message : String(err);
88
136
  return message.slice(0, 200);
@@ -579,18 +627,28 @@ async function sendTerminalMutationWithAuthReport(params, job, control) {
579
627
  return terminal;
580
628
  }
581
629
  /** Dispatch and run a single claimed job. */
582
- export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
583
630
  /**
584
- * BAPI-828: the runner-scoped control object, carrying the live worker
585
- * registry this job's worker registers with.
631
+ * Run ONE integration-correction worker under a spawn-job driver (BAPI-1259).
586
632
  *
587
- * A SEVENTH positional parameter, appended after the existing seams argument so
588
- * every current caller — the runner's default `runJob`, the many tests that
589
- * invoke this directly with four to six arguments — keeps compiling unchanged.
590
- * Absent means no executor-level shutdown control, which is exactly right for a
591
- * 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.
592
645
  */
593
- 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) {
594
652
  const ownership = {
595
653
  abandoned: false,
596
654
  lastSuccessfulHeartbeatAt: deps.now(),
@@ -606,6 +664,21 @@ control) {
606
664
  deps.errorLog(formatWorkerRateLimitAdvisory(advisory));
607
665
  },
608
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);
609
682
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
610
683
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
611
684
  // worker (TDD §5/§9).
@@ -615,6 +688,16 @@ control) {
615
688
  if (job.job_type === "smoke") {
616
689
  return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
617
690
  }
691
+ // BAPI-1160: `ci_rerun` is a deterministic executor action, dispatched here
692
+ // beside `merge`/`smoke` and BEFORE the generic spawn path. It ensures no
693
+ // worktree and spawns no worker, and it must NEVER fall back to one: spawning
694
+ // an LLM worker to "fix" a failure the server already proved to be
695
+ // infrastructure is exactly the behavior this job type replaces. `job-types.ts`
696
+ // keeps it out of both `SPAWN_JOB_TYPES` and `RECOVERY_JOB_TYPES`, so reaching
697
+ // the spawn path below is structurally impossible rather than merely unlikely.
698
+ if (job.job_type === "ci_rerun") {
699
+ return runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
700
+ }
618
701
  // BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
619
702
  // and before the generic spawn path. It needs a prepared, locked worktree like
620
703
  // a spawn job, but its body is the reconcile module's state machine: the
@@ -652,6 +735,121 @@ control) {
652
735
  });
653
736
  return { status: "failed", reason: "unsupported_job_type" };
654
737
  }
738
+ /**
739
+ * Run a deterministic `ci_rerun` job (BAPI-1160).
740
+ *
741
+ * Supervised through the SAME ownership, heartbeat, dead-man, and timeout
742
+ * protocol as {@link runMergeJob} — via {@link createFlowProcess} — so a
743
+ * deterministic job is not a second, weaker lifecycle. The body itself spawns no
744
+ * subprocess of its own beyond the bounded `gh` invocations inside
745
+ * `ci-rerun-job.ts`, and there is NO fallback to a worker on failure.
746
+ *
747
+ * The completion carries only the job's fact envelope. It names no gate,
748
+ * requests no transition, and asserts no verdict: the reconciler re-observes the
749
+ * head independently and remains the sole transition authority (R14 rule 3).
750
+ */
751
+ async function runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control) {
752
+ // Resolve + enforce payload.timeout_seconds BEFORE any provider work, exactly
753
+ // as the merge branch does.
754
+ const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
755
+ if (!timeout.ok) {
756
+ await httpClient.fail(job, {
757
+ error_kind: "ContractError.Timeout",
758
+ error_message: timeout.error,
759
+ classification: "crashed",
760
+ });
761
+ return { status: "failed", reason: "timeout_contract" };
762
+ }
763
+ const runCiRerun = seams.runCiRerun ??
764
+ ((payload) => runCiRerunJob(payload, createProductionCiRerunDeps()));
765
+ const flow = createFlowProcess(() => runCiRerun(job.payload), (result) => result.ok);
766
+ const procResult = await superviseProcess({
767
+ job,
768
+ httpClient,
769
+ options,
770
+ deps,
771
+ ownership,
772
+ observation,
773
+ proc: flow.proc,
774
+ timeoutSeconds: timeout.timeoutSeconds,
775
+ collectTelemetry: async () => ({}),
776
+ control,
777
+ });
778
+ if (ownership.abandoned) {
779
+ return { status: "abandoned", reason: ownership.abandonReason };
780
+ }
781
+ if (procResult.classification === "timeout") {
782
+ const terminal = await sendTerminalMutationWithAuthReport({
783
+ kind: "fail",
784
+ send: () => httpClient.fail(job, {
785
+ error_kind: "CiRerunFailed",
786
+ error_message: "ci_rerun exceeded its job timeout",
787
+ classification: "timeout",
788
+ telemetry: observation.snapshot(),
789
+ }),
790
+ deps,
791
+ options,
792
+ ownership,
793
+ log: deps.log,
794
+ }, job, control);
795
+ return terminalToRunResult(terminal, "failed");
796
+ }
797
+ const outcome = flow.getOutcome();
798
+ if (outcome === undefined) {
799
+ const terminal = await sendTerminalMutationWithAuthReport({
800
+ kind: "fail",
801
+ send: () => httpClient.fail(job, {
802
+ error_kind: "CiRerunFailed",
803
+ error_message: "ci_rerun completed without an outcome",
804
+ classification: "crashed",
805
+ telemetry: observation.snapshot(),
806
+ }),
807
+ deps,
808
+ options,
809
+ ownership,
810
+ log: deps.log,
811
+ }, job, control);
812
+ return terminalToRunResult(terminal, "failed");
813
+ }
814
+ if (outcome.ok) {
815
+ const completion = {
816
+ job_type: "ci_rerun",
817
+ exit_code: 0,
818
+ classification: "clean_exit",
819
+ // The FACT ENVELOPE, verbatim. No gate, no transition, no verdict — the
820
+ // backend model is `extra="forbid"`, so anything else fails validation
821
+ // rather than being silently ignored.
822
+ result: outcome.result,
823
+ telemetry: observation.snapshot(),
824
+ };
825
+ const terminal = await sendTerminalMutationWithAuthReport({
826
+ kind: "complete",
827
+ send: () => httpClient.complete(job, completion),
828
+ deps,
829
+ options,
830
+ ownership,
831
+ log: deps.log,
832
+ }, job, control);
833
+ return terminalToRunResult(terminal, "completed");
834
+ }
835
+ // A refused or failed re-run. The reserved infrastructure attempt stays
836
+ // CONSUMED — see `ci-rerun-job.ts` — and the reason is a closed token, never
837
+ // provider text.
838
+ const terminal = await sendTerminalMutationWithAuthReport({
839
+ kind: "fail",
840
+ send: () => httpClient.fail(job, {
841
+ error_kind: outcome.failure.errorKind,
842
+ error_message: outcome.failure.reason,
843
+ classification: "crashed",
844
+ telemetry: observation.snapshot(),
845
+ }),
846
+ deps,
847
+ options,
848
+ ownership,
849
+ log: deps.log,
850
+ }, job, control);
851
+ return terminalToRunResult(terminal, "failed");
852
+ }
655
853
  /**
656
854
  * Adapt a `runMerge(...)` promise into an {@link OwnedChildProcess} so the merge
657
855
  * flow is supervised by the SAME heartbeat/deadman/timeout path as spawn/smoke
@@ -2296,6 +2494,16 @@ async function runPreparedSpawn(params) {
2296
2494
  const authDetection = adapter.authFailureDetection;
2297
2495
  const authFailure = authDetection?.supported === true &&
2298
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);
2299
2507
  const terminal = await sendTerminalMutationWithAuthReport({
2300
2508
  kind: "fail",
2301
2509
  send: () => httpClient.fail(job, {
@@ -2306,6 +2514,7 @@ async function runPreparedSpawn(params) {
2306
2514
  classification: toWireClassification(procResult.classification),
2307
2515
  ...commitResidue(git),
2308
2516
  telemetry: observation.snapshot(),
2517
+ ...(capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}),
2309
2518
  }),
2310
2519
  deps,
2311
2520
  options,
@@ -26,6 +26,15 @@
26
26
  * resolution, but it does NOT re-enter `/implement-ticket`: it always carries an
27
27
  * explicit server-rendered `payload.prompt`, which wins over the synthesis in
28
28
  * `worker-command.ts`, and `job-runner.ts` dispatches it to its own body.
29
+ *
30
+ * BAPI-1160: `ci_rerun` is DELIBERATELY ABSENT from this set and from
31
+ * `SPAWN_JOB_TYPES` below. It is a deterministic actuator — one bounded
32
+ * `gh run rerun --failed` and a bounded poll — with no worktree, no branch to
33
+ * reuse, no prompt, and no worker. It has a dedicated dispatch branch in
34
+ * `job-runner.ts` beside `merge` and `smoke`, ahead of the generic spawn path,
35
+ * and it must NEVER fall back to a worker subprocess: spawning an LLM worker to
36
+ * "fix" a failure the server already PROVED to be infrastructure is the exact
37
+ * behavior this job type exists to replace.
29
38
  */
30
39
  export const RECOVERY_JOB_TYPES = new Set([
31
40
  "remediate",
@@ -37,8 +46,9 @@ export const RECOVERY_JOB_TYPES = new Set([
37
46
  * Real headless-spawn job types (worktree + worker LLM): the first-pass
38
47
  * `implement`, the `resume` continuation (own pre-spawn protocol), the
39
48
  * `spec_review` verdict job, and the recovery jobs. `merge` (deterministic,
40
- * no worktree/worker) and `smoke` (no-op acceptance) are intentionally EXCLUDED —
41
- * they have dedicated dispatch branches BEFORE the generic spawn path.
49
+ * no worktree/worker), `smoke` (no-op acceptance), and BAPI-1160's `ci_rerun`
50
+ * (deterministic, no worktree/worker) are intentionally EXCLUDED — they have
51
+ * dedicated dispatch branches BEFORE the generic spawn path.
42
52
  *
43
53
  * BAPI-1152: `reconcile_overlap` is here because it needs a prepared, locked
44
54
  * worktree and MAY spawn a worker — but only CONDITIONALLY. A clean merge whose
@@ -21,7 +21,7 @@
21
21
  * {@link MERGE_FAILED} `error_kind` (via {@link classifyMergeFailureErrorKind})
22
22
  * so the companion re-fire lane can route retry-vs-rebase without string parsing.
23
23
  */
24
- import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
24
+ import { makeLocalMergeExecutor, resolveLocalMergeCredential, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
25
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
26
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
27
27
  import { secretFreeErrorMessage } from "./job-errors.js";
@@ -353,5 +353,14 @@ export async function runExecutorMergeJob(job, seams) {
353
353
  * unchanged (no App token is ever placed here).
354
354
  */
355
355
  export function buildDefaultMergeLocalDeps(deps, signal) {
356
- return { env: deps.env, signal };
356
+ // BAPI-1136: the executor's merge job runs on the operator's own machine, which
357
+ // is typed `local`, so it reads the local configuration key and no other. The
358
+ // environment is still passed for the allowlisted operational base (PATH, HOME,
359
+ // certificates); it no longer supplies the identity, and an ambient GH_TOKEN in
360
+ // it is dropped rather than inherited.
361
+ return {
362
+ env: deps.env,
363
+ credential: resolveLocalMergeCredential(deps.env, "local"),
364
+ signal,
365
+ };
357
366
  }
@@ -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
  }