@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.
- package/README.md +86 -10
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/commands.generated.js +1 -1
- package/build/conductor/bridge-api-client.js +36 -8
- package/build/conductor/epic-runtime.js +133 -97
- package/build/conductor/readiness.js +85 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +5 -5
- package/build/doctor.js +68 -1
- package/build/drive-epic.js +287 -51
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/job-runner.js +60 -0
- package/build/index.js +128 -400
- package/build/local-artifact-storage.js +130 -0
- package/build/pipelines.generated.js +16 -9
- package/build/plane/cli.js +285 -36
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/shutdown.js +14 -1
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +514 -23
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1094
|
-
below). With
|
|
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 **
|
|
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
|
|
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
|
|
45
|
-
*
|
|
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 `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 `
|
|
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 === "
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
}
|