@bridge_gpt/mcp-server 0.2.12 → 0.2.13
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 +63 -1
- package/build/conductor/doctor.js +78 -1
- package/build/conductor/epic-runtime.js +126 -54
- package/build/conductor/epic-state.js +20 -5
- package/build/conductor/local-merge.js +212 -0
- package/build/conductor/pr-ci-producer.js +12 -2
- package/build/conductor/store.js +7 -4
- package/build/conductor/taxonomy.js +4 -0
- package/build/conductor-bin.js +476 -137
- package/build/doctor.js +3 -0
- package/build/index.js +1818 -441
- package/build/init.js +57 -0
- package/build/mcp-profile.js +33 -30
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +151 -0
- package/build/sfcc/config.js +39 -0
- package/build/sfcc/credentials.js +136 -0
- package/build/sfcc/ocapi-shape.js +77 -0
- package/build/sfcc/output.js +39 -0
- package/build/sfcc/permissions.js +136 -0
- package/build/sfcc/reads-custom-object-def.js +119 -0
- package/build/sfcc/reads-site-preference.js +158 -0
- package/build/sfcc/reads-system-object.js +162 -0
- package/build/sfcc/register.js +73 -0
- package/build/sfcc/setup-status.js +114 -0
- package/build/sfcc/tool-wrapper.js +70 -0
- package/build/start-tickets-conductor.js +9 -1
- package/build/start-tickets.js +47 -4
- package/build/version.generated.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -218,6 +218,8 @@ The MCP server also checks for updates automatically on startup. If a newer vers
|
|
|
218
218
|
|
|
219
219
|
This is the Bridge API tooling worth knowing about as a software engineer — the things you'd ask an agent to do — grouped by how often you would use them. Each entry covers **what it does**, **when it's useful** and **how to use it**. The behind-the-scenes plumbing is summarized at the end under [Extra Capabilities](#extra-capabilities), and a full enumeration lives in [Reference](#reference).
|
|
220
220
|
|
|
221
|
+
Working in a Salesforce B2C Commerce codebase? Bridge also ships read-only SFCC platform-introspection tools — see [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools).
|
|
222
|
+
|
|
221
223
|
For invocation, prefer the slash command — it's deterministic. A free-text example is shown only where natural-language phrasing reliably maps to the right automation; high-consequence or easily-misread automations show only the slash command on purpose.
|
|
222
224
|
|
|
223
225
|
### Tier 1 — Regularly useful
|
|
@@ -401,6 +403,66 @@ Behind-the-scenes capabilities an agent gains from the MCP tools — mostly invo
|
|
|
401
403
|
- **Retrieve any generated artifact** (`get_*` for plans, critiques, questions, brainstorms, research, architecture) without regenerating it.
|
|
402
404
|
- **Tiered-section execution telemetry** recording (internal measurement).
|
|
403
405
|
|
|
406
|
+
## Salesforce B2C Commerce (SFCC) Tools
|
|
407
|
+
|
|
408
|
+
Salesforce's official MCP server, `@salesforce/b2c-dx-mcp`, covers developer-experience tasks — cartridge deploy, step debugging, docs search, logs/MRT, and SCAPI Custom API scaffolding. As of its current toolset it has no way to *read* the platform's object model, custom objects, or site configuration — exactly the context an AI coding agent needs to write correct Commerce code and to inspect a sandbox before touching it. Bridge's SFCC tools install side-by-side with `b2c-dx-mcp` (they don't duplicate its surface) and close that gap with **read-only OCAPI Data API introspection** of system objects, custom object definitions, and site preferences.
|
|
409
|
+
|
|
410
|
+
**v1 is read-only and developer-sandbox-only** — no writes, and non-sandbox instances are rejected. Credentials stay local (in `dw.json` or `SFCC_*` env vars) and are never sent to Bridge.
|
|
411
|
+
|
|
412
|
+
<details>
|
|
413
|
+
<summary><strong>Setup</strong></summary>
|
|
414
|
+
|
|
415
|
+
The two diagnostic tools (`sfcc_setup_status`, `check_permissions`) are always available. The seven read tools must be enabled with a profile (step 3).
|
|
416
|
+
|
|
417
|
+
**Prerequisites:** a running SFCC **sandbox** and its hostname, plus an Account Manager API client (`client-id` + `client-secret`).
|
|
418
|
+
|
|
419
|
+
**1. Set the repo `version` config field** to your SFCC project type — one of `sfra | pwakit | sitegenesis | storefrontnext | hybrid`. The call-time gate reads this; a non-SFCC value blocks every SFCC tool except `sfcc_setup_status`. Set it via your normal config path, the `config_field` MCP tool (operation `update`, field `version`), or the `/teach-bridge` skill.
|
|
420
|
+
|
|
421
|
+
**2. Provide SFCC credentials.** Create a `dw.json` in your project root:
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{
|
|
425
|
+
"hostname": "zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com",
|
|
426
|
+
"client-id": "<account-manager-client-id>",
|
|
427
|
+
"client-secret": "<account-manager-client-secret>"
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Accepted key spellings: `hostname`/`host`, `client-id`/`clientId`/`client_id`, `client-secret`/`clientSecret`/`client_secret`. Prefer a single config — a multi-entry `configs[]` array forces you to pass an explicit `instance` on every call. `dw.json` is auto-added to git exclude and must never be committed. Alternatively, export `SFCC_HOSTNAME` / `SFCC_CLIENT_ID` / `SFCC_CLIENT_SECRET` in the MCP server environment.
|
|
432
|
+
|
|
433
|
+
**3. Enable the read tools.** Add `sfcc` to `BRIDGE_MCP_PROFILE` in the MCP server `env` block (it is comma-separated; `full` also works), then **restart the MCP client**:
|
|
434
|
+
|
|
435
|
+
```json
|
|
436
|
+
"env": { "BRIDGE_MCP_PROFILE": "sfcc" }
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Without this, only the diagnostic tools are registered.
|
|
440
|
+
|
|
441
|
+
**4. Verify and grant OCAPI access.** Ask your agent to run `sfcc_setup_status` (expect all checks ✓), then `check_permissions`. If it reports HTTP 401/403, it prints the exact OCAPI Settings JSON to paste in Business Manager (**Administration → Site Development → Open Commerce API Settings → Data API** tab); save it there and re-run. Restart the MCP client after any credential or env change — a running session does not pick them up.
|
|
442
|
+
|
|
443
|
+
</details>
|
|
444
|
+
|
|
445
|
+
### Tools
|
|
446
|
+
|
|
447
|
+
All SFCC tools are read-only and target a developer sandbox. Oversized responses are automatically saved to `BAPI_DOCS_DIR/sfcc/` and previewed inline.
|
|
448
|
+
|
|
449
|
+
**Diagnostics** (always available, no profile needed)
|
|
450
|
+
- `sfcc_setup_status` — report on every prerequisite: Bridge API key, repo name, `version` config, `dw.json` presence/uniqueness, and AM token acquisition.
|
|
451
|
+
- `check_permissions` — probe OCAPI access via `GET /system_object_definitions`; on 401/403, print the exact OCAPI Settings JSON to paste in Business Manager (split read-only vs. write/import grants).
|
|
452
|
+
|
|
453
|
+
**System object model** (needs the `sfcc` profile)
|
|
454
|
+
- `system_object_list` — list system object types (Product, Order, Customer, …).
|
|
455
|
+
- `system_object_get` — fetch one type's definition, optionally with its full attribute definitions (`expand_attribute_definitions`).
|
|
456
|
+
- `system_object_attribute_search` — search a type's attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.
|
|
457
|
+
|
|
458
|
+
**Custom object definitions** (needs the `sfcc` profile)
|
|
459
|
+
- `custom_object_definition_list` — list custom object type definitions.
|
|
460
|
+
- `custom_object_definition_get` — fetch an existing custom type with its key definition and attribute definitions/groups. Read-only — creating a custom object *type* isn't possible via OCAPI; that's a future v2 metadata-import capability.
|
|
461
|
+
|
|
462
|
+
**Site preferences** (needs the `sfcc` profile; sandbox only)
|
|
463
|
+
- `site_preference_get` — read a preference group's effective preferences.
|
|
464
|
+
- `site_preference_search` — search/filter preferences within a group.
|
|
465
|
+
|
|
404
466
|
## CLI Subcommands
|
|
405
467
|
|
|
406
468
|
Beyond `--init` / `--upgrade`, the package ships operational subcommands of the **single `bridge-api-mcp-server` bin** (not separate binaries) — so they travel with the package to every consumer. See [Usage Documentation → Start Tickets](#tier-1--regularly-useful) for *when* to use `start-tickets`; this section is the full CLI reference.
|
|
@@ -591,7 +653,7 @@ Reports are written to `<BAPI_DOCS_DIR>/smoke-test/REPORT-<host>-<timestamp>.md`
|
|
|
591
653
|
| `BAPI_WORKTRUNK_BIN` | No | `wt` (`git-wt` on Windows) | Override the Worktrunk executable name/path used by `start-tickets` for nonstandard installs |
|
|
592
654
|
| `BAPI_TMUX_SESSION` | No | `bridge-start-tickets` | Override the tmux session-name prefix used by `start-tickets` on Linux |
|
|
593
655
|
| `BAPI_MCP_UPGRADE_ADVICE_ENABLED` | No | _(enabled)_ | MCP-local opt-out for proactively surfacing upgrade advice in pipeline recipe preambles. Set to `false`/`0`/`no`/`off`/`disabled` to suppress. Disabling it does **not** change the `/jira/ping` response or server-side upgrade computation — it only gates the recipe-preamble convention |
|
|
594
|
-
| `BRIDGE_MCP_PROFILE` | No | `core` | Startup-time tool registration profile
|
|
656
|
+
| `BRIDGE_MCP_PROFILE` | No | `core` | Startup-time tool registration profile — a **comma-separated** list of groups controlling which tool groups are registered when the server starts. `core` is always implicitly included. Valid groups: `core` (default — normal coding tools only), `conductor` (+ 8 conductor/event/supervisor tools), `pipeline-authoring` (+ 5 pipeline run/admin tools — `get_pipeline_recipe` is NOT gated; it stays in `core` because the recipe-driven slash commands depend on it), `sfcc` (+ the 7 heavy SFCC read tools — see [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools); the `sfcc_setup_status`/`check_permissions` diagnostics are always registered regardless), and `full` (shortcut that expands to every group). Example: `sfcc,conductor`. Unknown, blank, or malformed tokens are dropped (falling back to `core`). Dynamic mid-session switching via `tools/list_changed` is unsupported — groups are resolved once at process startup. Conductor/epic sessions launched via `start-tickets --conductor` automatically inject `BRIDGE_MCP_PROFILE=conductor` at the spawn boundary; a normal `start-tickets` run stays on `core`. |
|
|
595
657
|
|
|
596
658
|
## Worktree credentials and the `mcp-invoke` shim
|
|
597
659
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* failure. This module performs NO writes: no hook installation, no schema
|
|
8
8
|
* migration, no event emission, and no scheduler unit creation.
|
|
9
9
|
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { doctorConductorLedger } from "./store.js";
|
|
11
12
|
import { inspectConductorGitHooks } from "./git-hooks.js";
|
|
12
13
|
/**
|
|
@@ -105,6 +106,65 @@ export function inspectMcpProfile(env, epicTick) {
|
|
|
105
106
|
}
|
|
106
107
|
return { resolved_profile, conductor_context_detected, degraded, warnings };
|
|
107
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Read-only probe of the local-merge (F4) capability. Runs `gh --version` and
|
|
111
|
+
* `gh auth status` (no writes, no merge) and reports whether the host could merge
|
|
112
|
+
* a PR locally if `policy_json.local_merge.enabled` were set. Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function inspectLocalMerge(runCommand) {
|
|
115
|
+
const run = runCommand ??
|
|
116
|
+
((cmd, args) => {
|
|
117
|
+
try {
|
|
118
|
+
// Static ESM import (this package is "type": "module"): a lazy
|
|
119
|
+
// require("child_process") throws ReferenceError under ESM, which the
|
|
120
|
+
// catch swallowed — making `gh` always look unavailable (false negative).
|
|
121
|
+
//
|
|
122
|
+
// `gh auth status` hits api.github.com; with no timeout a network stall
|
|
123
|
+
// (expired token, DNS hiccup, partition) blocks the doctor process until
|
|
124
|
+
// the OS TCP timeout fires (30s–2min). Bound it to 10s — a hung health
|
|
125
|
+
// check then simply reports `gh` unavailable. GH_PROMPT_DISABLED keeps an
|
|
126
|
+
// interactive prompt from holding the process past the timeout.
|
|
127
|
+
const r = spawnSync(cmd, args, {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
timeout: 10_000,
|
|
130
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1" },
|
|
131
|
+
});
|
|
132
|
+
return { status: r.status };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { status: null };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
let gh_available = false;
|
|
139
|
+
let gh_authed = false;
|
|
140
|
+
try {
|
|
141
|
+
gh_available = run("gh", ["--version"]).status === 0;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
gh_available = false;
|
|
145
|
+
}
|
|
146
|
+
if (gh_available) {
|
|
147
|
+
try {
|
|
148
|
+
gh_authed = run("gh", ["auth", "status"]).status === 0;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
gh_authed = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (!gh_available) {
|
|
156
|
+
warnings.push("`gh` is not installed or not on PATH. Local merge (policy_json.local_merge.enabled) " +
|
|
157
|
+
"cannot run; install the GitHub CLI to enable conductor-driven merges.");
|
|
158
|
+
}
|
|
159
|
+
else if (!gh_authed) {
|
|
160
|
+
warnings.push("`gh` is installed but not authenticated (`gh auth status` failed). Run `gh auth login` " +
|
|
161
|
+
"to grant the conductor merge permission; otherwise local merge will emit merge.failed/skip.");
|
|
162
|
+
}
|
|
163
|
+
// Capability gap only — local merge is opt-in, so an unauthed host is not a hard
|
|
164
|
+
// failure unless the operator enabled it. Reported as degraded for visibility.
|
|
165
|
+
const degraded = !gh_available || !gh_authed;
|
|
166
|
+
return { gh_available, gh_authed, degraded, warnings };
|
|
167
|
+
}
|
|
108
168
|
/**
|
|
109
169
|
* Build the combined read-only doctor report. Composes the existing ledger
|
|
110
170
|
* doctor, git hook inspection, and the epic-tick schedule enablement check.
|
|
@@ -120,6 +180,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
120
180
|
git_hooks: inspectHooks(deps.hooksDeps),
|
|
121
181
|
epic_tick: epicTick,
|
|
122
182
|
mcp_profile,
|
|
183
|
+
local_merge: inspectLocalMerge(deps.runCommand),
|
|
123
184
|
};
|
|
124
185
|
}
|
|
125
186
|
/**
|
|
@@ -128,7 +189,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
128
189
|
* status tags consistent with the git hooks section's visual hierarchy.
|
|
129
190
|
*/
|
|
130
191
|
export function formatConductorDoctorReport(report) {
|
|
131
|
-
const { ledger, git_hooks, epic_tick, mcp_profile } = report;
|
|
192
|
+
const { ledger, git_hooks, epic_tick, mcp_profile, local_merge } = report;
|
|
132
193
|
const lines = [
|
|
133
194
|
"Conductor ledger doctor",
|
|
134
195
|
"───────────────────────",
|
|
@@ -193,5 +254,21 @@ export function formatConductorDoctorReport(report) {
|
|
|
193
254
|
for (const w of mcp_profile.warnings)
|
|
194
255
|
lines.push(` - ${w}`);
|
|
195
256
|
}
|
|
257
|
+
lines.push("");
|
|
258
|
+
lines.push("Local merge capability (optional, opt-in)");
|
|
259
|
+
lines.push("─────────────────────────────────────────");
|
|
260
|
+
const ghTag = local_merge.gh_available
|
|
261
|
+
? local_merge.gh_authed
|
|
262
|
+
? "[OK]"
|
|
263
|
+
: "[WARNING] not authenticated"
|
|
264
|
+
: "[WARNING] not installed";
|
|
265
|
+
lines.push(`gh available: ${local_merge.gh_available} ${ghTag}`);
|
|
266
|
+
lines.push(`gh authenticated: ${local_merge.gh_authed}`);
|
|
267
|
+
lines.push(`degraded: ${local_merge.degraded}`);
|
|
268
|
+
if (local_merge.warnings.length > 0) {
|
|
269
|
+
lines.push("local merge warnings:");
|
|
270
|
+
for (const w of local_merge.warnings)
|
|
271
|
+
lines.push(` - ${w}`);
|
|
272
|
+
}
|
|
196
273
|
return lines.join("\n");
|
|
197
274
|
}
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { spawnSync } from "child_process";
|
|
21
21
|
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchPrReviewStatus, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, } from "./bridge-api-client.js";
|
|
22
22
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
23
|
+
import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
|
|
24
|
+
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
23
25
|
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
24
26
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
25
27
|
import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
|
|
@@ -38,12 +40,15 @@ const DEFAULT_LEASE_TTL_SECONDS = 120;
|
|
|
38
40
|
const DEFAULT_MAX_DRIFT_MS = 30_000;
|
|
39
41
|
const DEFAULT_DISPATCH_KEY_TTL_SECONDS = 300; // independent of supervision lease TTL
|
|
40
42
|
const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Module-private helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
41
46
|
/**
|
|
42
|
-
*
|
|
43
|
-
* parse
|
|
44
|
-
* the parse lock status on the next tick.
|
|
47
|
+
* Source + producer tags for the durable `parse.triggered` marker that the
|
|
48
|
+
* parse-after-merge wait emits from the epic-tick process.
|
|
45
49
|
*/
|
|
46
|
-
const
|
|
50
|
+
const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
|
|
51
|
+
const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
|
|
47
52
|
function defaultLeaseOwner() {
|
|
48
53
|
return `epic-tick-${process.pid}`;
|
|
49
54
|
}
|
|
@@ -220,17 +225,22 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
220
225
|
const settleMs = 5000;
|
|
221
226
|
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
222
227
|
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
228
|
+
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
223
229
|
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
224
230
|
const signal = observed.unfolded_terminal_signals[i];
|
|
225
231
|
if (signal.signal_type !== "merge.succeeded")
|
|
226
232
|
continue;
|
|
227
233
|
const ticketKey = signal.ticket_key;
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
const mergeEvent = signal.event;
|
|
235
|
+
const mergeTimeMs = new Date(mergeEvent.time).getTime();
|
|
236
|
+
// rebuildObservedState only surfaces a merge.succeeded whose run_id maps to
|
|
237
|
+
// a tracked dispatch, so mergeRunId is non-null and in the run-scoped
|
|
238
|
+
// localEvents read — binding the parse.triggered marker to it guarantees a
|
|
239
|
+
// later tick re-reads it. The merged PR head SHA (pre-merge) lives under
|
|
240
|
+
// the merge.* event's data.details; used only as an extra dedupe dimension.
|
|
241
|
+
const mergeRunId = mergeEvent.run_id ?? null;
|
|
242
|
+
const mergeDetails = mergeEvent.data?.details;
|
|
243
|
+
const mergeHeadSha = typeof mergeDetails?.head_sha === "string" ? mergeDetails.head_sha : undefined;
|
|
234
244
|
const revertSignal = () => {
|
|
235
245
|
const origStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ??
|
|
236
246
|
"running";
|
|
@@ -238,27 +248,72 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
238
248
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
239
249
|
i -= 1;
|
|
240
250
|
};
|
|
241
|
-
const
|
|
242
|
-
//
|
|
251
|
+
const currentPgStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ?? null;
|
|
252
|
+
// DURABLE wait state (replaces the former in-memory parseWaitStateMap, which
|
|
253
|
+
// was lost between stateless epic-tick processes → re-triggered the parse on
|
|
254
|
+
// every idle tick and never folded to `done`). The marker is a
|
|
255
|
+
// `parse.triggered` ledger event correlated to this ticket's dispatch run_id
|
|
256
|
+
// and emitted strictly after the merge — so its presence is the skew-free
|
|
257
|
+
// signal that the post-merge parse was already kicked off.
|
|
258
|
+
const parseTriggeredEvent = localEvents.find((e) => e.type === "parse.triggered" &&
|
|
259
|
+
e.subject === ticketKey &&
|
|
260
|
+
e.run_id === mergeRunId &&
|
|
261
|
+
new Date(e.time).getTime() >= mergeTimeMs);
|
|
262
|
+
const elapsedMs = nowFn() - mergeTimeMs;
|
|
263
|
+
// Budget exhaustion (measured from the durable merge.succeeded time):
|
|
264
|
+
// escalate once (gated on the durable Postgres status, not an in-memory
|
|
265
|
+
// flag) then block. A subsequent tick finds it already blocked and drops
|
|
266
|
+
// the signal without re-escalating or re-CASing.
|
|
243
267
|
if (elapsedMs > maxWaitMs) {
|
|
244
|
-
if (
|
|
245
|
-
await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
|
|
246
|
-
pState.escalated = true;
|
|
247
|
-
signal.next_status = "blocked";
|
|
268
|
+
if (currentPgStatus === "blocked") {
|
|
248
269
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
249
|
-
// Let the signal remain so the reconcile pass CASes once to blocked
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
else {
|
|
253
|
-
// Already escalated: map to blocked and skip redundant CAS
|
|
254
|
-
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
255
|
-
parseWaitStateMap.delete(stateKey);
|
|
256
270
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
257
271
|
i -= 1;
|
|
258
272
|
continue;
|
|
259
273
|
}
|
|
274
|
+
await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
|
|
275
|
+
signal.next_status = "blocked";
|
|
276
|
+
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
277
|
+
// Let the signal remain so the reconcile pass CASes once to blocked.
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!parseTriggeredEvent) {
|
|
281
|
+
// No durable trigger for this merge yet — fire the parse and record the
|
|
282
|
+
// marker, then hold at ready_for_review for a later tick to fold.
|
|
283
|
+
try {
|
|
284
|
+
await triggerParseFn(access);
|
|
285
|
+
emitConductorEventFn({
|
|
286
|
+
source: PARSE_WAIT_EVENT_SOURCE,
|
|
287
|
+
type: "parse.triggered",
|
|
288
|
+
subject: ticketKey,
|
|
289
|
+
run_id: mergeRunId,
|
|
290
|
+
worker_id: mergeEvent.worker_id ?? null,
|
|
291
|
+
producer: PARSE_WAIT_EVENT_PRODUCER,
|
|
292
|
+
observed_via: "supervisor",
|
|
293
|
+
time: new Date(nowFn()).toISOString(),
|
|
294
|
+
data: {
|
|
295
|
+
summary: `parse-after-merge triggered for ${ticketKey}`,
|
|
296
|
+
details: {
|
|
297
|
+
epic_key,
|
|
298
|
+
ticket_key: ticketKey,
|
|
299
|
+
...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
}, {
|
|
303
|
+
event_type: "parse.triggered",
|
|
304
|
+
run_id: mergeRunId ?? undefined,
|
|
305
|
+
commit_sha: mergeHeadSha,
|
|
306
|
+
});
|
|
307
|
+
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
311
|
+
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
312
|
+
}
|
|
313
|
+
revertSignal();
|
|
314
|
+
continue;
|
|
260
315
|
}
|
|
261
|
-
//
|
|
316
|
+
// A durable parse.triggered exists for this merge — poll the live lock.
|
|
262
317
|
let parseStatusResult;
|
|
263
318
|
try {
|
|
264
319
|
parseStatusResult = await fetchParseStatusFn(access);
|
|
@@ -270,38 +325,26 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
270
325
|
continue;
|
|
271
326
|
}
|
|
272
327
|
if (parseStatusResult.status === "in_progress") {
|
|
273
|
-
|
|
328
|
+
// Parse still running — hold.
|
|
274
329
|
revertSignal();
|
|
275
330
|
continue;
|
|
276
331
|
}
|
|
277
|
-
// status === "idle"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
parseWaitStateMap.delete(stateKey);
|
|
292
|
-
continue; // let the signal proceed to CAS → done
|
|
293
|
-
}
|
|
294
|
-
// No trigger yet — fire it now
|
|
295
|
-
try {
|
|
296
|
-
await triggerParseFn(access);
|
|
297
|
-
pState.triggeredAt = nowFn();
|
|
298
|
-
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
299
|
-
}
|
|
300
|
-
catch (err) {
|
|
301
|
-
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
302
|
-
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
332
|
+
// status === "idle". If the marker is younger than the settle window, the
|
|
333
|
+
// async parse may not have acquired its lock yet (idle is a false negative)
|
|
334
|
+
// — hold one more tick. Past the settle window, idle means the post-merge
|
|
335
|
+
// parse has completed (or finished instantly), so let the merge.succeeded
|
|
336
|
+
// signal proceed to CAS → done.
|
|
337
|
+
//
|
|
338
|
+
// KNOWN LIMITATION: /jira/parse-status only reports {in_progress, idle} —
|
|
339
|
+
// it cannot distinguish a FAILED parse from a completed one, so an instant
|
|
340
|
+
// parse failure folds to `done` here. Pre-existing; tracked as P2 (surface
|
|
341
|
+
// a parse failure/last_error from parse-status).
|
|
342
|
+
const msSinceTrigger = nowFn() - new Date(parseTriggeredEvent.time).getTime();
|
|
343
|
+
if (msSinceTrigger < settleMs) {
|
|
344
|
+
revertSignal();
|
|
345
|
+
continue;
|
|
303
346
|
}
|
|
304
|
-
|
|
347
|
+
// Completed: do not revert — the signal proceeds to CAS → done.
|
|
305
348
|
}
|
|
306
349
|
}
|
|
307
350
|
// Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
|
|
@@ -486,7 +529,27 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
486
529
|
});
|
|
487
530
|
},
|
|
488
531
|
dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
|
|
489
|
-
processMerge: async (acc, event) =>
|
|
532
|
+
processMerge: async (acc, event) => {
|
|
533
|
+
// F4: when policy_json.local_merge.enabled is set (opt-in, default OFF)
|
|
534
|
+
// and the caller did not inject a processMerge stub, run the real merge
|
|
535
|
+
// pipeline with a LOCAL executor swapped in for the backend route — the
|
|
536
|
+
// merge happens here using the agent's own `gh` credentials, so the
|
|
537
|
+
// hosted backend never needs global GitHub write scope. Otherwise the
|
|
538
|
+
// default backend-route path is unchanged for all existing users.
|
|
539
|
+
if (deps.processMerge === undefined) {
|
|
540
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
541
|
+
const localCfg = epicRunState.epic_run.policy_json?.local_merge;
|
|
542
|
+
if (localCfg?.enabled === true) {
|
|
543
|
+
return processGateMetMerge(acc, event, {
|
|
544
|
+
merge: makeLocalMergeExecutor({
|
|
545
|
+
method: resolveLocalMergeMethod(localCfg.method),
|
|
546
|
+
approvalRequired: localCfg.approval_required === true,
|
|
547
|
+
}, { env: process.env }),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return processMergeFn(acc, event);
|
|
552
|
+
},
|
|
490
553
|
postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
|
|
491
554
|
escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
|
|
492
555
|
log,
|
|
@@ -812,12 +875,21 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
812
875
|
dryRun: dispatchDryRun,
|
|
813
876
|
autoApprove: true,
|
|
814
877
|
maxParallel: 1,
|
|
815
|
-
|
|
878
|
+
// F-base: a merge-gated dependent MUST cut from the predecessor's merged
|
|
879
|
+
// code. With refreshMain:false the worktree was cut from a STALE local
|
|
880
|
+
// `main` (never fetched/ff'd after the predecessor merged on origin), so
|
|
881
|
+
// dependents built without the predecessor's code — defeating the whole
|
|
882
|
+
// merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
|
|
883
|
+
refreshMain: true,
|
|
816
884
|
branchOverrides: {},
|
|
817
885
|
baseBranch: "main",
|
|
818
886
|
conductorEnabled: true,
|
|
819
887
|
// BAPI-441: re-dispatch reuses the existing branch/worktree.
|
|
820
888
|
resumeMode: isResume,
|
|
889
|
+
// F7: on a FRESH dispatch, refuse a stale leftover `feature/<KEY>` branch
|
|
890
|
+
// (e.g. a prior run's worktree) rather than silently building on it. No
|
|
891
|
+
// effect on resume (which reuses a located worktree via a different path).
|
|
892
|
+
guardStaleWorktree: !isResume,
|
|
821
893
|
}, {
|
|
822
894
|
createConductorContext: createStartTicketsConductorContext,
|
|
823
895
|
provisionConductorHooksForRows,
|
|
@@ -193,6 +193,14 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
193
193
|
const pendingMergeEvents = [];
|
|
194
194
|
// Track which tickets already have a folded signal (one override per ticket)
|
|
195
195
|
const foldedTicketKeys = new Set();
|
|
196
|
+
// Track which tickets already have a gate.met queued for merge this tick, so a
|
|
197
|
+
// second gate.met for the same ticket never double-enqueues. Distinct from
|
|
198
|
+
// foldedTicketKeys: a prior run.stopped fold (→ ready_for_review) must NOT
|
|
199
|
+
// suppress a later gate.met's merge enqueue (both map to ready_for_review;
|
|
200
|
+
// only gate.met enqueues a merge, and the worker frequently emits run.stopped
|
|
201
|
+
// BEFORE a post-hoc gate.met). Only a fold to a non-mergeable status (blocked)
|
|
202
|
+
// suppresses the merge — handled via the effective-status check below.
|
|
203
|
+
const mergeQueuedTicketKeys = new Set();
|
|
196
204
|
// BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
|
|
197
205
|
// tracked across the full ledger so an already-blocked ticket still carries a
|
|
198
206
|
// reason for the remediation pass to frame the nudge.
|
|
@@ -233,12 +241,19 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
233
241
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
234
242
|
if (!isNonTerminal(postgresStatus))
|
|
235
243
|
continue;
|
|
236
|
-
//
|
|
237
|
-
// this tick
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
244
|
+
// Queue gate.met for merge actioning unless (a) this ticket's effective
|
|
245
|
+
// status this tick is non-mergeable ("blocked" — e.g. a ci.failed that
|
|
246
|
+
// folded earlier in the same batch, or a Postgres-blocked ticket whose
|
|
247
|
+
// ci.failed re-folds every tick), or (b) a gate.met for it is already
|
|
248
|
+
// queued. `postgresStatus` already reflects same-tick folds (the loop
|
|
249
|
+
// mutates `ticketStatusMap`), so a prior run.stopped fold leaves it
|
|
250
|
+
// "ready_for_review" and the merge proceeds — fixing the deadlock where a
|
|
251
|
+
// worker's run.stopped (lower seq) suppressed a later operator/CI gate.met.
|
|
252
|
+
if (event.type === "gate.met" &&
|
|
253
|
+
postgresStatus !== "blocked" &&
|
|
254
|
+
!mergeQueuedTicketKeys.has(ticketKey)) {
|
|
241
255
|
pendingMergeEvents.push(event);
|
|
256
|
+
mergeQueuedTicketKeys.add(ticketKey);
|
|
242
257
|
}
|
|
243
258
|
const signalType = event.type;
|
|
244
259
|
const nextStatus = signalToNextStatus(signalType, isReview);
|