@bridge_gpt/mcp-server 0.2.53 → 0.2.54

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 (40) hide show
  1. package/README.md +86 -10
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +1 -1
  7. package/build/conductor/bridge-api-client.js +36 -8
  8. package/build/conductor/epic-runtime.js +133 -97
  9. package/build/conductor/readiness.js +85 -0
  10. package/build/conductor/run-branch.js +137 -0
  11. package/build/conductor/test-run-branch-vectors.js +165 -0
  12. package/build/conductor-bin.js +5 -5
  13. package/build/doctor.js +68 -1
  14. package/build/drive-epic.js +287 -51
  15. package/build/executor/claim-scope.js +104 -0
  16. package/build/executor/cli.js +14 -25
  17. package/build/executor/env-file-guard.js +82 -3
  18. package/build/executor/job-runner.js +60 -0
  19. package/build/index.js +128 -400
  20. package/build/local-artifact-storage.js +130 -0
  21. package/build/pipelines.generated.js +16 -9
  22. package/build/plane/cli.js +285 -36
  23. package/build/plane/manifest.js +209 -1
  24. package/build/plane/member-roster.js +70 -0
  25. package/build/plane/shutdown.js +14 -1
  26. package/build/plane/status.js +35 -1
  27. package/build/plane/supervisor.js +546 -164
  28. package/build/plane/types.js +25 -2
  29. package/build/polling-policy.js +72 -0
  30. package/build/readme.generated.js +1 -1
  31. package/build/review-generation.js +219 -0
  32. package/build/run-unit-tests-launcher.js +5 -0
  33. package/build/setup-epic.js +514 -23
  34. package/build/ticket-key-utils.js +4 -3
  35. package/build/ticket-review-artifact-gate.js +461 -0
  36. package/build/upgrade-cli.js +5 -26
  37. package/build/version.generated.js +3 -3
  38. package/docs/install/mcp-tool-integrations.md +23 -1
  39. package/package.json +1 -1
  40. package/pipelines/review-ticket.json +17 -4
package/README.md CHANGED
@@ -352,7 +352,7 @@ later boot.
352
352
  "mcpServers": {
353
353
  "bridge": {
354
354
  "command": "npx",
355
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"],
355
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"],
356
356
  "env": {
357
357
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
358
358
  "BAPI_REPO_NAME": "your-repo",
@@ -374,7 +374,7 @@ later boot.
374
374
  "bridge": {
375
375
  "type": "stdio",
376
376
  "command": "npx",
377
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"],
377
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"],
378
378
  "env": {
379
379
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
380
380
  "BAPI_REPO_NAME": "your-repo",
@@ -396,7 +396,7 @@ later boot.
396
396
  "bridge": {
397
397
  "type": "stdio",
398
398
  "command": "npx",
399
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"],
399
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"],
400
400
  "env": {
401
401
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
402
402
  "BAPI_REPO_NAME": "your-repo",
@@ -421,7 +421,7 @@ you select `copilot-cli`; the shape below is what it produces.
421
421
  "bridge": {
422
422
  "type": "local",
423
423
  "command": "npx",
424
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"],
424
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"],
425
425
  "tools": ["*"],
426
426
  "env": {
427
427
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
@@ -445,7 +445,7 @@ Windsurf only supports global MCP configuration.
445
445
  "mcpServers": {
446
446
  "bridge": {
447
447
  "command": "npx",
448
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"],
448
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"],
449
449
  "env": {
450
450
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
451
451
  "BAPI_REPO_NAME": "your-repo",
@@ -464,7 +464,7 @@ Windsurf only supports global MCP configuration.
464
464
  ```toml
465
465
  [mcp_servers.bridge]
466
466
  command = "npx"
467
- args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"]
467
+ args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.54", "serve"]
468
468
 
469
469
  [mcp_servers.bridge.env]
470
470
  BAPI_BASE_URL = "https://bridgegpt-api.com"
@@ -1090,12 +1090,54 @@ all, because an unknown owner is not the same as a not-ready one. No branch,
1090
1090
  including every error path, ever offers you two paths.
1091
1091
 
1092
1092
  `drive-epic` owns no branch behavior of its own — it forwards `--feature-branch
1093
- <name>` and `--into-base` verbatim to `setup-epic`, which decides the strategy (see
1094
- below). With neither flag, a multi-ticket epic runs on `epic/<KEY>`.
1093
+ <name>`, `--into-base`, and `--attended` verbatim to `setup-epic`, which decides
1094
+ the strategy and the posture (see below). With none of them, a multi-ticket epic
1095
+ runs on `epic/<KEY>`, unattended.
1096
+
1097
+ **The composed bring-up is two-phase (BAPI-1102).** With `--plan-file` on a
1098
+ repository whose readiness is missing only the runtime facts, one invocation runs,
1099
+ in order: the **control plane** (Bridge API server + reconciler, each gated on its
1100
+ own readiness), then `setup-epic` to create and approve the run, then **executor
1101
+ lanes scoped to that run** with `--epic-run-id <run>`, then the **dead-man
1102
+ observer** last, then the ready banner. The order is forced: an executor lane must
1103
+ carry an explicit claim scope, and the run it is scoped to does not exist until the
1104
+ server is up and `setup-epic` has run.
1105
+
1106
+ A composed plane serves **exactly one** run; start a second plane for a second
1107
+ epic. If `setup-epic` fails, the control plane is left running and **no executor
1108
+ lane is spawned**. If the lanes fail to become ready, the control plane and the
1109
+ committed run are both left intact and the observer is not started — nothing is
1110
+ rolled back, because a local process that failed to start says nothing about
1111
+ whether the run is valid.
1112
+
1113
+ "Plane ready" reports **processes**, not dispatchability: if the run cuts an epic
1114
+ branch, the reconciler holds ticket dispatch until that branch's index scope
1115
+ finishes preparing.
1095
1116
 
1096
1117
  Two conductors is a transitional state. When one is eliminated, `drive-epic` is
1097
1118
  the only thing that changes.
1098
1119
 
1120
+ ### `plane up` — claim scope is required
1121
+
1122
+ `plane up` starts the runtime on its own, and every executor lane it starts needs
1123
+ an explicit claim scope:
1124
+
1125
+ ```
1126
+ npx -y @bridge_gpt/mcp-server plane up --epic-run-id <run-id> # repeatable
1127
+ npx -y @bridge_gpt/mcp-server plane up --repo-wide # or repository-wide
1128
+ ```
1129
+
1130
+ The two are mutually exclusive. Asked to start lanes with neither, `plane up`
1131
+ **refuses before spawning anything**, in the executor's own words. That is not a
1132
+ new restriction — an executor started with no claim scope has refused to start
1133
+ since BAPI-1026 — it is that refusal moved to where it is actionable, instead of
1134
+ surfacing as a member crash and a full plane rollback.
1135
+
1136
+ `plane status` reports the lifecycle phase (control plane starting/ready, lanes
1137
+ starting/ready, observer active, plane ready) and the epic run the plane is bound
1138
+ to. `plane down` handles every intermediate phase: it stops the bound run first,
1139
+ then signals the recorded process group.
1140
+
1099
1141
  ### `setup-epic`
1100
1142
 
1101
1143
  Bootstraps an Epic Conductor v2 run in one command — creates the epic run, stores the plan DAG, and approves it:
@@ -1118,6 +1160,39 @@ branch, matching plain `start-tickets`.
1118
1160
  > name, or to force a branch for a single-node plan. Passing both is a parse error,
1119
1161
  > refused before any file read, credential resolution, or network call.
1120
1162
 
1163
+ **A run that selects an epic branch is created UNATTENDED (BAPI-1102).** Its
1164
+ `policy_json` carries `posture: "unattended"` and `v2_auto_merge_enabled: true`, so
1165
+ child PRs merge into the epic branch without parking for a human at every gate. The
1166
+ **integration PR into the repository base branch stays human-gated.** The run's CI
1167
+ gate is stamped **server-side at first approval** — from your repository's declared
1168
+ default, or, when it declares none, as an explicit recorded `no_ci_gate` mode.
1169
+
1170
+ > **Behavior change (BAPI-1102).** Pass `--attended` to restore the previous
1171
+ > composition: `base_branch` plus an explicitly requested `--review-policy`, with no
1172
+ > posture and no auto-merge authorization. `--into-base`, single-node plans, and
1173
+ > branch-silent invocations are **unchanged** — they send no `policy_json` at all,
1174
+ > byte for byte as before. A `--policy-file` still supersedes composition entirely;
1175
+ > combining `--attended` with a file that declares `posture: "unattended"` is a named
1176
+ > refusal rather than a silent winner.
1177
+
1178
+ An unattended run has two one-time **repository** prerequisites — consent
1179
+ (`unattended_conductor_allowed`) and a verified notify webhook default — plus
1180
+ confirmed default-branch review/CI workflows. Each is refused by name **before any
1181
+ run row is created** (`target_not_allowed`, `webhook_unverified`,
1182
+ `repository_readiness_unconfirmed`), and `drive-epic` readiness and `doctor` both
1183
+ list them with their current state. `notify.local_sink` does not satisfy the webhook
1184
+ prerequisite.
1185
+
1186
+ > **Rollout note.** The client-side default and the server-side admission,
1187
+ > stamping, and readiness changes must be released as **one compatible unit**. The
1188
+ > new default's shape (`v2_auto_merge_enabled: true` with no `required_checks`) is
1189
+ > exactly what the create route refused before BAPI-1102, so shipping the client
1190
+ > half alone would 422 every unattended run at `POST /jira/epic-runs/runs`.
1191
+ > Deploying only the server half is safe and inert: it narrows two refusals and adds
1192
+ > a first-approval stamp, and **rewrites no existing run**. No database migration and
1193
+ > no new environment variable is introduced — `config_projects.unattended_conductor_allowed`
1194
+ > and `epic_supervisor_setup.done_gate_config` already exist and are read as-is.
1195
+
1121
1196
  Once the plan is approved, the **server-side reconciler** picks the run up within ~30s. To execute claimed jobs on your machine, run `executor`:
1122
1197
 
1123
1198
  ```
@@ -1487,17 +1562,18 @@ The full surface, for when you need the complete enumeration. Day-to-day, use [U
1487
1562
 
1488
1563
  ### MCP tools
1489
1564
 
1490
- The authoritative tool catalog covers **73 tools** (enumerated below). What's actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).
1565
+ The authoritative tool catalog covers **75 tools** (enumerated below). What's actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).
1491
1566
 
1492
1567
  - **Connectivity & identity** — `ping` (its JSON also carries `docs_dir`, `role`, and `customer_type`)
1493
1568
  - **Team & access** — `invite_member` (admin-only; mints a scoped access key for a teammate on an already-configured project — the plaintext key is shown exactly once)
1494
1569
  - **Jira tickets** — `get_tickets`, `get_ticket`, `create_ticket`, `update_ticket_description`, `add_comment`, `get_comments`
1495
1570
  - **Attachments** — `attachment` (operations: `upload`, `download`, `list`)
1496
1571
  - **AI generation (request/get)** — `request_plan_generation`/`get_plan`, `create_doc`/`get_doc` (design docs by `doc_type`: tdd/architecture/fsd/prd, where `architecture` is an alias of `tdd`), `request_ticket_review` (writes both `get_clarifying_questions` and `get_ticket_critique`), `request_reimplement_context`/`get_reimplement_context`, `request_council`/`get_council`, `request_deep_research`/`get_deep_research`
1572
+ - **Bounded waits** — `wait_for_ticket_review`, `wait_for_ci_checks`. Each call waits at most 240 s and returns `state: "ready" | "pending" | "error"`; `pending` is a normal, resumable result you call again on, never a failure. They exist so an agent can wait without a foreground `sleep` and without binding a long server-side operation to the 900 s MCP client deadline. `wait_for_ci_checks` reports only that checks are **terminal** — deciding pass or fail stays with the caller.
1497
1573
  - **Other AI** — `second_opinion`, `generate_image`, `generate_decision_page`, `visual_diff` (deterministic pixel diff of a rendered URL vs a design comp)
1498
1574
  - **Ticket lifecycle** — `track_ticket`, `update_ticket_state`, `get_ticket_state`, `get_ticket_state_tree` (live repo-wide lifecycle + dependency tree; read-only, no mutation parameter)
1499
1575
  - **Jira status** — `update_jira_status` (pass `status: "auto"` to resolve the configured post-PR status server-side)
1500
- - **Repository & CI** — `parse_repository` (`action`: `start`, `status`), `regenerate_directory_map`, `create_pull_request`, `merge_pull_request`, `resolve_ci_checks`, `poll_ci_checks`
1576
+ - **Repository & CI** — `parse_repository` (`action`: `start`, `status`), `regenerate_directory_map`, `create_pull_request`, `merge_pull_request`, `resolve_ci_checks`, `poll_ci_checks`, `wait_for_ci_checks` (the last two are hidden until `ci_check_config` resolves, and share one visibility gate)
1501
1577
  - **Pipelines & automation** — `get_pipeline_recipe` (returns a fully resolved recipe the agent executes step-by-step)
1502
1578
  - **Config** — `get_project_standards`, `config_field` (operations: `get`, `update`, `list`)
1503
1579
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Resolves the `claude` binary against the *explicitly supplied PATH* (not the
5
5
  * ambient process default) and builds the launched-run invocation by delegating
6
- * to the shared `renderScheduledPrompt` renderer (no hard-coded `/full-automation`
6
+ * to the shared `renderScheduledPrompt` renderer (no hard-coded per-command
7
7
  * prompt). It emits `{ exe, args: ["-p", prompt] }`. Claude Code has no
8
8
  * working-directory flag, so the cwd is always set by the launching unit — this
9
9
  * adapter must never add a working-directory argument to the invocation.
@@ -41,8 +41,8 @@ export async function resolveCommandOnPath(command, envPath, deps) {
41
41
  return pathApi.normalize(candidate);
42
42
  }
43
43
  /**
44
- * Backward-compatibility shim for callers that quoted an idea-file path for a
45
- * `/full-automation` prompt. Generalized prompt-token quoting now lives in
44
+ * Backward-compatibility shim for callers that quoted an idea-file path for the
45
+ * pre-rename automation prompt. Generalized prompt-token quoting now lives in
46
46
  * `quotePromptToken`; this preserves the original quote semantics (always wrap in
47
47
  * double quotes, escape embedded quotes) for any remaining path callers.
48
48
  */
@@ -32,15 +32,14 @@ function schemaSupportsAutoFlag(schema) {
32
32
  * the normalized `input.args`, plus (only where the command can parse them)
33
33
  * `--scheduled-at <ISO>` and `--auto`.
34
34
  *
35
- * `--scheduled-at` is appended for `full-automation` (legacy) and `epic-tick`
36
- * (BAPI-418), both of whose parsers accept it as a first-class argument. Every
37
- * other command rejects unrecognized flags and halts (e.g. `start-tickets` stops
38
- * on any unsupported flag), and the shared late-fire gate already embeds the
39
- * scheduled time so injecting `--scheduled-at` into their argv would break an
40
- * otherwise launchable command for no benefit.
35
+ * `--scheduled-at` is appended for `epic-tick` (BAPI-418), whose parser accepts
36
+ * it as a first-class argument. Every other command rejects unrecognized flags
37
+ * and halts (e.g. `start-tickets` stops on any unsupported flag), and the shared
38
+ * late-fire gate already embeds the scheduled time so injecting `--scheduled-at`
39
+ * into their argv would break an otherwise launchable command for no benefit.
41
40
  *
42
41
  * `--auto` is appended when auto-approve is set AND the command supports it (its
43
- * schema declares a boolean `--auto` flag, or it is `full-automation` / `epic-tick`).
42
+ * schema declares a boolean `--auto` flag, or it is `epic-tick`).
44
43
  * It is never duplicated if already present in `input.args`.
45
44
  *
46
45
  * This is the SINGLE source of the delegated argv: both the rendered target
@@ -54,12 +53,10 @@ function buildAugmentedArgs(input) {
54
53
  // epic-tick already accepts --scheduled-at (parseEpicTickArgs, cli.ts) so it
55
54
  // receives the scheduled time as a structured arg for the late-fire decision,
56
55
  // not just via the embedded gate text.
57
- if (input.commandName === "full-automation" || input.commandName === "epic-tick") {
56
+ if (input.commandName === "epic-tick") {
58
57
  args.push("--scheduled-at", input.runAtIso);
59
58
  }
60
- const supportsAuto = schemaSupportsAutoFlag(input.schema) ||
61
- input.commandName === "full-automation" ||
62
- input.commandName === "epic-tick";
59
+ const supportsAuto = schemaSupportsAutoFlag(input.schema) || input.commandName === "epic-tick";
63
60
  const alreadyHasAuto = input.args.includes("--auto");
64
61
  if (input.autoApprove && supportsAuto && !alreadyHasAuto) {
65
62
  args.push("--auto");
package/build/base-ref.js CHANGED
@@ -28,16 +28,38 @@ import { commandSucceeded } from "./start-tickets-prereqs.js";
28
28
  /**
29
29
  * Returns an error string for an unsafe branch name, or null when valid.
30
30
  *
31
- * Rejects names that Git itself refuses (`git check-ref-format`) as well as
32
- * injection-shaped inputs, WITHOUT invoking Git: an empty/whitespace-only name,
33
- * a leading `-` (which git would parse as a flag, e.g. `--upload-pack=evil`),
34
- * ASCII control characters, a `..` sequence, and a `.lock` suffix. Every failure
35
- * is a short, secret-free reason string that names only the rule that failed.
31
+ * This is the project's BOUNDED SAFETY SUBSET, not an implementation of
32
+ * `git check-ref-format`. It rejects, without invoking Git: an empty or
33
+ * whitespace-only name, a name longer than 255 Unicode code points, a leading
34
+ * `-` (which git would parse as a flag, e.g. `--upload-pack=evil`), a `..`
35
+ * sequence, a `.lock` suffix, and ASCII control characters. Every failure is a
36
+ * short, secret-free reason string naming only the rule that failed — never the
37
+ * offending value.
38
+ *
39
+ * The rules Git also refuses but this subset does NOT — `~`, `^`, `:`, `?`,
40
+ * `*`, `[`, `@{`, a backslash, and a trailing dot — are deliberate follow-up
41
+ * work, recorded here so nobody reads this as comprehensive ref validation. The
42
+ * subset is chosen to stop flag injection and command-shaped input, which is the
43
+ * safety property the callers actually depend on.
44
+ *
45
+ * BAPI-1127: this validator has a hand-written Python mirror,
46
+ * `feature_branch_provisioning._validate_provisioned_branch`, and the two are
47
+ * pinned rule-for-rule by the shared vectors in
48
+ * `tests/pytest/fixtures/run_branch_vectors.json`. The length rule counts
49
+ * UNICODE CODE POINTS, matching Python's `len()`. It previously used
50
+ * `String.length`, which counts UTF-16 code units, so a name of non-BMP
51
+ * characters (each a surrogate PAIR) measured double and the two runtimes
52
+ * disagreed about the same name at the boundary.
53
+ *
54
+ * Rule ORDER is part of the contract: the first failing rule is the one
55
+ * reported, so the vectors can name exactly one `error_type` per invalid input.
36
56
  */
37
57
  export function validateBranchName(branch) {
38
58
  if (branch.trim().length === 0)
39
59
  return "branch name must not be empty.";
40
- if (branch.length > 255)
60
+ // Spread to code points (never `.length`) so this measures the same units
61
+ // Python's `len()` does.
62
+ if ([...branch].length > 255)
41
63
  return "branch name must be 255 characters or fewer.";
42
64
  if (branch.startsWith("-"))
43
65
  return "branch name must not start with '-'.";
@@ -46,9 +68,11 @@ export function validateBranchName(branch) {
46
68
  if (branch.endsWith(".lock"))
47
69
  return "branch name must not end with '.lock'.";
48
70
  // Reject ASCII control characters (0x00-0x1F and 0x7F) without embedding
49
- // raw control bytes in source.
50
- for (let i = 0; i < branch.length; i++) {
51
- const code = branch.charCodeAt(i);
71
+ // raw control bytes in source. Iterated by code point for the same reason the
72
+ // length rule is: a surrogate pair is one character, and neither half of one
73
+ // can be an ASCII control character anyway.
74
+ for (const ch of branch) {
75
+ const code = ch.codePointAt(0);
52
76
  if (code <= 0x1f || code === 0x7f) {
53
77
  return "branch name must not contain control characters.";
54
78
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * A bounded, resumable wait primitive (BAPI-1104, R77).
3
+ *
4
+ * WHY THIS EXISTS. Across the BAPI-1061 and BAPI-1085 unattended runs the single
5
+ * largest source of wasted attempts was workers that could not wait. The Claude
6
+ * Code harness refuses a foreground `sleep`, and the MCP client deadline is 900
7
+ * seconds — so a recipe that waited by sleeping could not run at all, and one
8
+ * that waited by blocking a single tool call for 900 s died mid-wait, exited
9
+ * without its verdict artifact, and was requeued. Both failures cost a full
10
+ * attempt from a two-attempt budget for work that had already been done.
11
+ *
12
+ * The fix is a wait the caller can RESUME. `boundedWait` occupies at most
13
+ * {@link MAX_BUDGET_MS} (240 s — comfortably inside the 900 s cap), and when that
14
+ * window elapses it returns `{state: "pending"}`, which is a NORMAL, expected
15
+ * result, not an error. The caller loops: each call is a fresh bounded window,
16
+ * and an arbitrarily long upstream operation is covered by however many windows
17
+ * the caller's own budget allows. Nothing about the job's lease, heartbeat, or
18
+ * dead-man timing changes, because the worker never stops making tool calls.
19
+ *
20
+ * WHAT THIS MODULE IS NOT. It performs no I/O of its own. It imports no HTTP
21
+ * helper, knows no endpoint path, and names no agent CLI — every upstream read
22
+ * happens inside the caller-supplied `probe`. That is what lets the planned
23
+ * `cursor-agent` and Codex adapters use the identical primitive, and it is what
24
+ * keeps the reason vocabulary below closed: the primitive can only report states
25
+ * it can itself observe.
26
+ *
27
+ * NO RAW UPSTREAM DATA ESCAPES. A result carries a closed-vocabulary reason and
28
+ * nothing else. Exception text, response bodies, headers, URLs, and credentials
29
+ * are never read into a result — a caller that needs upstream detail must obtain
30
+ * it inside its own probe and sanitize it there, the same bar
31
+ * `formatRecoverablePollGiveUp` sets for the existing poller.
32
+ *
33
+ * TIMER DISCIPLINE. Every timer this module creates is registered and cancelled
34
+ * in an outer `finally`, on every exit path including an unexpected throw. The
35
+ * timer seams are injectable because `VirtualClock.tickUntil` in
36
+ * `executor/test-clock.ts` flushes microtasks only and cannot advance a real
37
+ * `setTimeout` — a primitive that reached for the global timer would make its own
38
+ * deterministic tests hang rather than fail, which is the worse of the two.
39
+ */
40
+ import { MAX_CONSECUTIVE_POLL_FAILURES, MAX_PROBE_TIMEOUT_MS, MAX_POLL_JITTER_MS, defaultPollDelayMs, } from "./polling-policy.js";
41
+ /** Hard ceiling on one wait window. Well inside the 900 s MCP client deadline. */
42
+ export const MAX_BUDGET_MS = 240_000;
43
+ const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function"
44
+ ? performance.now()
45
+ : Date.now();
46
+ /**
47
+ * Wait for `probe` to report ready, for at most `budgetMs` (≤ 240 s).
48
+ *
49
+ * Returns `ready` with the probe's value, `pending` when the window closed or
50
+ * transport kept failing, or `error` over the closed reason vocabulary. Never
51
+ * throws: an unexpected exception from a caller's `isReady` or timer seam still
52
+ * unwinds through the `finally` that cancels every outstanding timer.
53
+ */
54
+ export async function boundedWait(options) {
55
+ const { budgetMs, probe, isReady, signal, deps } = options;
56
+ // Caller error, reported rather than clamped: a budget that is NaN, infinite,
57
+ // or sub-millisecond is a computed value that went wrong upstream, and
58
+ // silently substituting 240 s would hide the bug behind a working wait.
59
+ if (typeof budgetMs !== "number" || !Number.isFinite(budgetMs) || budgetMs < 1) {
60
+ return { state: "error", reason: "INVALID_ARGUMENT" };
61
+ }
62
+ const effectiveBudgetMs = Math.min(budgetMs, MAX_BUDGET_MS);
63
+ const now = deps?.now ?? monotonicNow;
64
+ const setTimer = deps?.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
65
+ const clearTimer = deps?.clearTimer ?? ((handle) => clearTimeout(handle));
66
+ const jitterMs = deps?.jitterMs ?? (() => Math.random() * MAX_POLL_JITTER_MS);
67
+ // EVERY timer created below is registered here and cancelled in the outer
68
+ // `finally`. A leaked timer in an MCP server process keeps the event loop
69
+ // alive and, in a test, keeps a virtual clock advancing past the assertion.
70
+ const liveTimers = new Set();
71
+ const track = (handle) => {
72
+ liveTimers.add(handle);
73
+ return handle;
74
+ };
75
+ const release = (handle) => {
76
+ liveTimers.delete(handle);
77
+ clearTimer(handle);
78
+ };
79
+ const startedAt = now();
80
+ const deadlineAt = startedAt + effectiveBudgetMs;
81
+ const remaining = () => deadlineAt - now();
82
+ /** Sleep through an injected timer, resolving early if the caller aborts. */
83
+ const delay = (ms) => new Promise((resolve) => {
84
+ if (ms <= 0) {
85
+ resolve();
86
+ return;
87
+ }
88
+ let handle;
89
+ const finish = () => {
90
+ signal?.removeEventListener("abort", finish);
91
+ if (handle !== undefined)
92
+ release(handle);
93
+ resolve();
94
+ };
95
+ handle = track(setTimer(finish, ms));
96
+ signal?.addEventListener("abort", finish, { once: true });
97
+ });
98
+ let consecutiveFailures = 0;
99
+ try {
100
+ while (true) {
101
+ if (signal?.aborted)
102
+ return { state: "error", reason: "ABORTED" };
103
+ if (remaining() <= 0) {
104
+ return { state: "pending", reason: "WAIT_WINDOW_ELAPSED" };
105
+ }
106
+ // One controller per probe, aborted by EITHER the per-probe timeout or the
107
+ // caller's signal. The probe therefore never outlives its own budget, and a
108
+ // hung upstream costs one probe timeout rather than the whole window.
109
+ const probeController = new AbortController();
110
+ const abortProbe = () => probeController.abort();
111
+ signal?.addEventListener("abort", abortProbe, { once: true });
112
+ const probeTimeoutMs = Math.max(1, Math.min(MAX_PROBE_TIMEOUT_MS, remaining()));
113
+ let timedOut = false;
114
+ const timeoutHandle = track(setTimer(() => {
115
+ timedOut = true;
116
+ probeController.abort();
117
+ }, probeTimeoutMs));
118
+ let outcome = null;
119
+ let threw = false;
120
+ try {
121
+ outcome = await probe(probeController.signal);
122
+ }
123
+ catch {
124
+ // The probe's exception NEVER reaches a result. A transport throw and a
125
+ // probe timeout are the same observation — "no answer this time" — and
126
+ // both are transient until the shared ceiling says otherwise.
127
+ threw = true;
128
+ }
129
+ finally {
130
+ release(timeoutHandle);
131
+ signal?.removeEventListener("abort", abortProbe);
132
+ }
133
+ // Caller cancellation outranks whatever the in-flight probe reported: the
134
+ // caller stopped caring, and it is not an upstream observation.
135
+ if (signal?.aborted)
136
+ return { state: "error", reason: "ABORTED" };
137
+ if (threw || timedOut || outcome === null) {
138
+ consecutiveFailures += 1;
139
+ if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
140
+ return { state: "pending", reason: "UPSTREAM_TRANSIENT" };
141
+ }
142
+ }
143
+ else if (outcome.kind === "terminal") {
144
+ return { state: "error", reason: "UPSTREAM_TERMINAL" };
145
+ }
146
+ else if (outcome.kind === "malformed") {
147
+ return { state: "error", reason: "INVALID_RESPONSE" };
148
+ }
149
+ else {
150
+ // The server answered. Reset the streak BEFORE evaluating readiness — an
151
+ // answer of "not yet" is still proof the transport is healthy.
152
+ consecutiveFailures = 0;
153
+ if (outcome.ready === true) {
154
+ const value = outcome.value;
155
+ if (isReady === undefined || isReady(value)) {
156
+ return { state: "ready", result: value };
157
+ }
158
+ }
159
+ }
160
+ if (remaining() <= 0) {
161
+ return { state: "pending", reason: "WAIT_WINDOW_ELAPSED" };
162
+ }
163
+ // The existing poller's curve plus bounded jitter, clamped to whatever is
164
+ // actually left so a backoff can never overrun the deadline it is inside.
165
+ const backoff = defaultPollDelayMs(now() - startedAt) + jitterMs();
166
+ await delay(Math.min(backoff, remaining()));
167
+ }
168
+ }
169
+ finally {
170
+ for (const handle of liveTimers)
171
+ clearTimer(handle);
172
+ liveTimers.clear();
173
+ }
174
+ }