@bridge_gpt/mcp-server 0.2.19 → 0.2.21
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 +6 -3
- package/build/agents.generated.js +1 -1
- package/build/commands.generated.js +4 -3
- package/build/conductor/local-merge.js +458 -95
- package/build/estimate-epic.js +84 -0
- package/build/executor/job-runner.js +151 -17
- package/build/executor/merge-job.js +84 -10
- package/build/executor/worker-finalization.js +98 -18
- package/build/index.js +1843 -401
- package/build/pipelines.generated.js +16 -20
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +15 -5
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1583 -117
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +2792 -449
- package/public/js/main.min.js.map +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* estimate-epic.ts — orchestration for the deterministic `estimate_epic` MCP tool
|
|
3
|
+
* (BAPI-523).
|
|
4
|
+
*
|
|
5
|
+
* `index.ts` stays the thin registration surface; input validation, request
|
|
6
|
+
* shaping, and response formatting live here so they are unit-testable without a
|
|
7
|
+
* real network call or credential store. Mirrors the `visual-diff.ts` pattern:
|
|
8
|
+
* this module never imports `index.ts` — the caller injects `buildUrl`,
|
|
9
|
+
* `getPostHeaders`, and `handleResponse` (index.ts's existing fetch helpers) as
|
|
10
|
+
* deps. `epic_key` and `ticket_keys` are mutually exclusive and validated BEFORE
|
|
11
|
+
* any network call; no `mode` parameter exists — the source is inferred from
|
|
12
|
+
* which key input is supplied. There is no `recreate` parameter: the BAPI-522
|
|
13
|
+
* orchestrator does not currently expose one (see `api/routes/estimate_epic.py`
|
|
14
|
+
* for the backend-side note), so this tool intentionally does not accept one
|
|
15
|
+
* either.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Validate mutually-exclusive/required key inputs before any network call.
|
|
19
|
+
* Returns a human-readable error message, or `null` when the input is valid.
|
|
20
|
+
*/
|
|
21
|
+
export function validateEstimateEpicInput(input) {
|
|
22
|
+
const hasEpic = typeof input.epic_key === "string" && input.epic_key.trim().length > 0;
|
|
23
|
+
const hasKeys = Array.isArray(input.ticket_keys);
|
|
24
|
+
if (hasEpic && hasKeys) {
|
|
25
|
+
return "epic_key and ticket_keys are mutually exclusive; supply exactly one, never both.";
|
|
26
|
+
}
|
|
27
|
+
if (!hasEpic && !hasKeys) {
|
|
28
|
+
return "Exactly one of epic_key or ticket_keys is required.";
|
|
29
|
+
}
|
|
30
|
+
if (hasKeys && input.ticket_keys.length === 0) {
|
|
31
|
+
return "ticket_keys was supplied but contained no keys; provide at least one, non-empty ticket_keys.";
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** A structured, JSON-safe validation/error envelope — never a successful estimate payload. */
|
|
36
|
+
export function buildEstimateEpicErrorEnvelope(code, message, extras) {
|
|
37
|
+
return JSON.stringify({ error: code, message, ...(extras ?? {}) }, null, 2);
|
|
38
|
+
}
|
|
39
|
+
/** Serialize a payload with stable two-space indentation for MCP text content. */
|
|
40
|
+
export function toolText(payload) {
|
|
41
|
+
return typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run the `estimate_epic` tool: validate input, POST to the Bridge API
|
|
45
|
+
* `/jira/estimate-epic` endpoint, and return MCP text content. Never throws —
|
|
46
|
+
* every failure (validation, network, backend error) becomes a structured JSON
|
|
47
|
+
* error envelope in the returned text content.
|
|
48
|
+
*/
|
|
49
|
+
export async function runEstimateEpic(input, deps) {
|
|
50
|
+
const validationError = validateEstimateEpicInput(input);
|
|
51
|
+
if (validationError) {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: buildEstimateEpicErrorEnvelope("VALIDATION_ERROR", validationError) }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const payload = { repo_name: deps.repoName };
|
|
57
|
+
if (typeof input.epic_key === "string")
|
|
58
|
+
payload.epic_key = input.epic_key;
|
|
59
|
+
if (Array.isArray(input.ticket_keys))
|
|
60
|
+
payload.ticket_keys = input.ticket_keys;
|
|
61
|
+
if (typeof input.allow_partial === "boolean")
|
|
62
|
+
payload.allow_partial = input.allow_partial;
|
|
63
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
64
|
+
let resp;
|
|
65
|
+
try {
|
|
66
|
+
resp = await fetchImpl(deps.buildUrl("/estimate-epic"), {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: await deps.getPostHeaders(),
|
|
69
|
+
body: JSON.stringify(payload),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: buildEstimateEpicErrorEnvelope("NETWORK_ERROR", "Failed to reach the Bridge API estimate-epic endpoint."),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const text = await deps.handleResponse(resp);
|
|
83
|
+
return { content: [{ type: "text", text }] };
|
|
84
|
+
}
|
|
@@ -18,7 +18,7 @@ import os from "node:os";
|
|
|
18
18
|
import { buildExecutorWorkerEnv } from "./env.js";
|
|
19
19
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
20
|
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
|
|
21
|
-
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, } from "./merge-job.js";
|
|
21
|
+
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
22
|
import { createObservationState } from "./observation.js";
|
|
23
23
|
import { provisionExecutorDenyLayer } from "./permissions.js";
|
|
24
24
|
import { runProcessWithTimeout } from "./process.js";
|
|
@@ -89,8 +89,13 @@ function buildJobLogRegistryDeps(deps) {
|
|
|
89
89
|
platform: deps.platform,
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Default merge dispatch: resolve Bridge API access, then run the deterministic
|
|
94
|
+
* merge. Exported for unit tests (access-resolution failure → retryable). The
|
|
95
|
+
* `controls.signal` (overall-timeout abort) is threaded into local merge deps
|
|
96
|
+
* without changing the local `gh` credential model.
|
|
97
|
+
*/
|
|
98
|
+
export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
|
|
94
99
|
const accessResult = await buildConductorMergeAccessForExecutorJob({
|
|
95
100
|
env: deps.env,
|
|
96
101
|
cwd: deps.cwd,
|
|
@@ -100,10 +105,13 @@ async function defaultRunMergeForClaimed(job, deps, _options) {
|
|
|
100
105
|
stat: deps.stat,
|
|
101
106
|
});
|
|
102
107
|
if (!accessResult.ok) {
|
|
108
|
+
// Access resolution feeds only the executor's read-only CI re-poll; a missing
|
|
109
|
+
// or temporarily-unavailable Bridge API access is transient infrastructure, so
|
|
110
|
+
// it is RETRYABLE (BAPI-572). The local `gh` credential model is unchanged.
|
|
103
111
|
return {
|
|
104
112
|
ok: false,
|
|
105
113
|
failure: {
|
|
106
|
-
error_kind:
|
|
114
|
+
error_kind: MERGE_RETRYABLE,
|
|
107
115
|
error_message: accessResult.error,
|
|
108
116
|
classification: "crashed",
|
|
109
117
|
},
|
|
@@ -111,7 +119,9 @@ async function defaultRunMergeForClaimed(job, deps, _options) {
|
|
|
111
119
|
}
|
|
112
120
|
return runExecutorMergeJob(job, {
|
|
113
121
|
access: accessResult.access,
|
|
114
|
-
|
|
122
|
+
// Thread the overall-timeout abort signal into local merge deps WITHOUT
|
|
123
|
+
// changing the local `gh` credential model (the signal is not a credential).
|
|
124
|
+
localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
|
|
115
125
|
});
|
|
116
126
|
}
|
|
117
127
|
/** An in-memory owned process for the no-op `smoke` acceptance job. */
|
|
@@ -192,7 +202,7 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
192
202
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
193
203
|
// worker (TDD §5/§9).
|
|
194
204
|
if (job.job_type === "merge") {
|
|
195
|
-
return runMergeJob(job, httpClient, options, deps, ownership, seams);
|
|
205
|
+
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
196
206
|
}
|
|
197
207
|
if (job.job_type === "smoke") {
|
|
198
208
|
return runSmokeJob(job, httpClient, options, deps, ownership, observation);
|
|
@@ -213,23 +223,146 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
213
223
|
return { status: "failed", reason: "unsupported_job_type" };
|
|
214
224
|
}
|
|
215
225
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* (
|
|
226
|
+
* Adapt a `runMerge(...)` promise into an {@link OwnedChildProcess} so the merge
|
|
227
|
+
* flow is supervised by the SAME heartbeat/deadman/timeout path as spawn/smoke
|
|
228
|
+
* jobs even though it spawns no worker. `wait()` resolves when the merge outcome
|
|
229
|
+
* is available (exit_code 0 on `ok`, 1 otherwise); `kill()` aborts the internal
|
|
230
|
+
* {@link AbortController} (cancelling in-flight local `gh` work) and resolves the
|
|
231
|
+
* owned process as killed. The stored outcome/error let the runner map the
|
|
232
|
+
* supervised result to `/complete` or `/fail` afterwards (killed/timeout is read
|
|
233
|
+
* from the supervised `ProcessRunResult`/`ownership`, not from this adapter).
|
|
219
234
|
*/
|
|
220
|
-
|
|
221
|
-
const
|
|
235
|
+
function createMergeFlowProcess(startMerge) {
|
|
236
|
+
const controller = new AbortController();
|
|
222
237
|
let outcome;
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
let error;
|
|
239
|
+
let done = false;
|
|
240
|
+
let settleWait = () => { };
|
|
241
|
+
const waitPromise = new Promise((resolve) => {
|
|
242
|
+
settleWait = resolve;
|
|
243
|
+
});
|
|
244
|
+
// Start the merge flow immediately; capture its resolution/rejection.
|
|
245
|
+
startMerge(controller.signal).then((result) => {
|
|
246
|
+
outcome = result;
|
|
247
|
+
if (!done) {
|
|
248
|
+
done = true;
|
|
249
|
+
settleWait({ exitCode: result.ok ? 0 : 1, signal: null });
|
|
250
|
+
}
|
|
251
|
+
}, (err) => {
|
|
252
|
+
error = err;
|
|
253
|
+
if (!done) {
|
|
254
|
+
done = true;
|
|
255
|
+
settleWait({ exitCode: 1, signal: null });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
const proc = {
|
|
259
|
+
pid: undefined,
|
|
260
|
+
stdout: null,
|
|
261
|
+
stderr: null,
|
|
262
|
+
async wait() {
|
|
263
|
+
return waitPromise;
|
|
264
|
+
},
|
|
265
|
+
kill(signal) {
|
|
266
|
+
if (done)
|
|
267
|
+
return;
|
|
268
|
+
controller.abort();
|
|
269
|
+
done = true;
|
|
270
|
+
settleWait({ exitCode: null, signal });
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
proc,
|
|
275
|
+
getOutcome: () => outcome,
|
|
276
|
+
getError: () => error,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run a deterministic `merge` job: NO worktree ensured, NO worker spawned. Unlike
|
|
281
|
+
* the pre-BAPI-572 version, the merge flow now runs under the SHARED heartbeat/
|
|
282
|
+
* deadman/timeout supervision path (via {@link superviseProcess}) so a live merge
|
|
283
|
+
* heartbeats (the janitor never re-queues a healthy multi-minute merge) and the
|
|
284
|
+
* whole flow honors `payload.timeout_seconds`. A timeout is reported as a
|
|
285
|
+
* RETRYABLE failure and a late merge resolution after timeout/abandonment is
|
|
286
|
+
* ignored. The local merge executor's outcome otherwise drives `/complete`
|
|
287
|
+
* (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
|
|
288
|
+
*/
|
|
289
|
+
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
290
|
+
// Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
|
|
291
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
292
|
+
if (!timeout.ok) {
|
|
293
|
+
await httpClient.fail(job, {
|
|
294
|
+
error_kind: "ContractError.Timeout",
|
|
295
|
+
error_message: timeout.error,
|
|
296
|
+
classification: "crashed",
|
|
297
|
+
});
|
|
298
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
225
299
|
}
|
|
226
|
-
|
|
300
|
+
const timeoutSeconds = timeout.timeoutSeconds;
|
|
301
|
+
const runMerge = seams.runMerge ?? defaultRunMergeForClaimed;
|
|
302
|
+
const flow = createMergeFlowProcess((signal) => runMerge(job, deps, options, { signal }));
|
|
303
|
+
const procResult = await superviseProcess({
|
|
304
|
+
job,
|
|
305
|
+
httpClient,
|
|
306
|
+
options,
|
|
307
|
+
deps,
|
|
308
|
+
ownership,
|
|
309
|
+
observation,
|
|
310
|
+
proc: flow.proc,
|
|
311
|
+
timeoutSeconds,
|
|
312
|
+
collectTelemetry: async () => ({}),
|
|
313
|
+
});
|
|
314
|
+
// Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
|
|
315
|
+
// mutation for a claim we no longer own, and never process a late outcome.
|
|
316
|
+
if (ownership.abandoned) {
|
|
317
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
318
|
+
}
|
|
319
|
+
// Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
|
|
320
|
+
// any later merge promise resolution (the abort has already been signalled).
|
|
321
|
+
if (procResult.classification === "timeout") {
|
|
227
322
|
const terminal = await sendTerminalMutationWithRetry({
|
|
228
323
|
kind: "fail",
|
|
229
324
|
send: () => httpClient.fail(job, {
|
|
230
|
-
error_kind:
|
|
231
|
-
error_message:
|
|
325
|
+
error_kind: MERGE_RETRYABLE,
|
|
326
|
+
error_message: `merge flow timed out after ${timeoutSeconds} seconds`,
|
|
327
|
+
classification: "timeout",
|
|
328
|
+
telemetry: observation.snapshot(),
|
|
329
|
+
}),
|
|
330
|
+
deps,
|
|
331
|
+
options,
|
|
332
|
+
ownership,
|
|
333
|
+
log: deps.log,
|
|
334
|
+
});
|
|
335
|
+
return terminalToRunResult(terminal, "failed");
|
|
336
|
+
}
|
|
337
|
+
// A thrown error inside the merge flow maps to a bounded MergeFailed.
|
|
338
|
+
const flowError = flow.getError();
|
|
339
|
+
if (flowError !== undefined) {
|
|
340
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
341
|
+
kind: "fail",
|
|
342
|
+
send: () => httpClient.fail(job, {
|
|
343
|
+
error_kind: MERGE_FAILED,
|
|
344
|
+
error_message: secretFreeErrorMessage(flowError),
|
|
232
345
|
classification: "crashed",
|
|
346
|
+
telemetry: observation.snapshot(),
|
|
347
|
+
}),
|
|
348
|
+
deps,
|
|
349
|
+
options,
|
|
350
|
+
ownership,
|
|
351
|
+
log: deps.log,
|
|
352
|
+
});
|
|
353
|
+
return terminalToRunResult(terminal, "failed");
|
|
354
|
+
}
|
|
355
|
+
const outcome = flow.getOutcome();
|
|
356
|
+
if (outcome === undefined) {
|
|
357
|
+
// Clean process result with no stored outcome — an internal invariant break;
|
|
358
|
+
// fail loud rather than silently reporting success.
|
|
359
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
360
|
+
kind: "fail",
|
|
361
|
+
send: () => httpClient.fail(job, {
|
|
362
|
+
error_kind: MERGE_FAILED,
|
|
363
|
+
error_message: "merge flow completed without an outcome",
|
|
364
|
+
classification: "crashed",
|
|
365
|
+
telemetry: observation.snapshot(),
|
|
233
366
|
}),
|
|
234
367
|
deps,
|
|
235
368
|
options,
|
|
@@ -244,6 +377,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
|
|
|
244
377
|
exit_code: 0,
|
|
245
378
|
classification: "clean_exit",
|
|
246
379
|
result: outcome.result,
|
|
380
|
+
telemetry: observation.snapshot(),
|
|
247
381
|
};
|
|
248
382
|
const terminal = await sendTerminalMutationWithRetry({
|
|
249
383
|
kind: "complete",
|
|
@@ -257,7 +391,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
|
|
|
257
391
|
}
|
|
258
392
|
const terminal = await sendTerminalMutationWithRetry({
|
|
259
393
|
kind: "fail",
|
|
260
|
-
send: () => httpClient.fail(job, outcome.failure),
|
|
394
|
+
send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
|
|
261
395
|
deps,
|
|
262
396
|
options,
|
|
263
397
|
ownership,
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* GitHub, so no action-key idempotency protocol is needed. On success the merge
|
|
11
11
|
* outcome (accepted / merge commit) is posted at `/complete`; gate advancement is
|
|
12
12
|
* left entirely to the merge OBSERVER (T4/T5) — this module never advances a gate.
|
|
13
|
+
*
|
|
14
|
+
* BAPI-572: the local `gh` auth model is unchanged (no App credentials — locked
|
|
15
|
+
* decision). Two behaviors were hardened here:
|
|
16
|
+
* - Ambiguous merge failures are resolved by re-reading the merged state inside
|
|
17
|
+
* {@link makeLocalMergeExecutor}, so an already-merged PR at the expected head
|
|
18
|
+
* maps to a SUCCESS result (with the merge commit SHA), not a failure.
|
|
19
|
+
* - Merge-domain failures no longer collapse into one `error_kind` — they emit a
|
|
20
|
+
* machine-readable {@link MERGE_RETRYABLE}, {@link MERGE_CONFLICT}, or
|
|
21
|
+
* {@link MERGE_FAILED} `error_kind` (via {@link classifyMergeFailureErrorKind})
|
|
22
|
+
* so the companion re-fire lane can route retry-vs-rebase without string parsing.
|
|
13
23
|
*/
|
|
14
24
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
|
|
15
25
|
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
@@ -17,6 +27,10 @@ import { secretFreeErrorMessage } from "./job-errors.js";
|
|
|
17
27
|
function positiveIntOrNull(value) {
|
|
18
28
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
19
29
|
}
|
|
30
|
+
/** Read an optional non-negative integer (preserving 0), else `undefined`. */
|
|
31
|
+
function nonNegativeIntOrUndefined(value) {
|
|
32
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
33
|
+
}
|
|
20
34
|
function asString(value) {
|
|
21
35
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
22
36
|
}
|
|
@@ -40,9 +54,11 @@ export function readMergeJobPayloadFields(job) {
|
|
|
40
54
|
? payload.required_checks.filter((c) => typeof c === "string")
|
|
41
55
|
: [];
|
|
42
56
|
const actionKey = asString(payload.action_key) ?? `merge:pr-${prNumber}:${expectedHeadSha}`;
|
|
57
|
+
const ciWaitTimeoutMs = nonNegativeIntOrUndefined(payload.ci_wait_timeout_ms);
|
|
58
|
+
const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
|
|
43
59
|
return {
|
|
44
60
|
ok: true,
|
|
45
|
-
fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey },
|
|
61
|
+
fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey, ciWaitTimeoutMs, ciWaitPollIntervalMs },
|
|
46
62
|
};
|
|
47
63
|
}
|
|
48
64
|
/** Construct the {@link ConductorMergeRequest} for the local merge executor. */
|
|
@@ -97,11 +113,62 @@ export function buildMergeJobResult(response, fields) {
|
|
|
97
113
|
}
|
|
98
114
|
return result;
|
|
99
115
|
}
|
|
100
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Machine-readable merge-domain error kinds (BAPI-572). The companion re-fire
|
|
118
|
+
* lane routes retry-vs-rebase on these values, NOT on free text:
|
|
119
|
+
* - {@link MERGE_RETRYABLE}: transient gh/network/auth-refresh, `ci_not_green` —
|
|
120
|
+
* safe to re-fire the same merge later.
|
|
121
|
+
* - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
|
|
122
|
+
* - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
|
|
123
|
+
*/
|
|
124
|
+
export const MERGE_RETRYABLE = "MergeRetryable";
|
|
125
|
+
export const MERGE_CONFLICT = "MergeConflict";
|
|
126
|
+
export const MERGE_FAILED = "MergeFailed";
|
|
127
|
+
/** Reasons whose failure is transient/infrastructure and safe to retry. */
|
|
128
|
+
const RETRYABLE_MERGE_REASONS = new Set([
|
|
129
|
+
"gh_pr_view_timeout",
|
|
130
|
+
"gh_pr_view_failed",
|
|
131
|
+
"gh_pr_view_unparseable",
|
|
132
|
+
"ci_poll_failed",
|
|
133
|
+
"ci_not_green",
|
|
134
|
+
"gh_merge_timeout",
|
|
135
|
+
"gh_merge_failed",
|
|
136
|
+
"merge_aborted",
|
|
137
|
+
]);
|
|
138
|
+
/**
|
|
139
|
+
* Classify a non-succeeded merge response into a machine-readable `error_kind`.
|
|
140
|
+
* A `merge.conflict` ledger event (or a `gh_merge_conflict` reason) is a conflict;
|
|
141
|
+
* transient/infrastructure reasons are retryable; everything else — including a
|
|
142
|
+
* missing/malformed reason — is a deterministic {@link MERGE_FAILED}.
|
|
143
|
+
*
|
|
144
|
+
* BAPI-577 Part A: the CI re-poll now emits distinct `ci_poll_*` reasons
|
|
145
|
+
* (`ci_poll_timeout`, `ci_poll_unauthorized`, `ci_poll_http_<status>`, …) instead of a
|
|
146
|
+
* single `ci_poll_failed`. All of them are transient infrastructure reads, so any
|
|
147
|
+
* `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
|
|
148
|
+
* variants covered so a distinct reason never accidentally downgrades to
|
|
149
|
+
* {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
|
|
150
|
+
*/
|
|
151
|
+
export function classifyMergeFailureErrorKind(response) {
|
|
152
|
+
const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
|
|
153
|
+
const reason = asString(response.reason ?? undefined);
|
|
154
|
+
if (hasConflictEvent || reason === "gh_merge_conflict")
|
|
155
|
+
return MERGE_CONFLICT;
|
|
156
|
+
if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
|
|
157
|
+
return MERGE_RETRYABLE;
|
|
158
|
+
}
|
|
159
|
+
return MERGE_FAILED;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Convert a non-succeeded local-merge response into a bounded, secret-free
|
|
163
|
+
* executor failure. `error_kind` is machine-readable (retryable/conflict/failed);
|
|
164
|
+
* `error_message` includes only the sanitized merge status and reason (never raw
|
|
165
|
+
* `gh` output). `classification` stays within the executor's vocabulary; the
|
|
166
|
+
* `error_kind` is the routing signal.
|
|
167
|
+
*/
|
|
101
168
|
export function buildMergeJobFailure(response) {
|
|
102
169
|
const reason = asString(response.reason ?? undefined) ?? response.status;
|
|
103
170
|
return {
|
|
104
|
-
error_kind:
|
|
171
|
+
error_kind: classifyMergeFailureErrorKind(response),
|
|
105
172
|
error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
|
|
106
173
|
classification: "crashed",
|
|
107
174
|
};
|
|
@@ -126,7 +193,11 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
126
193
|
const fields = resolution.fields;
|
|
127
194
|
const request = buildConductorMergeRequestForExecutorJob(fields, seams.access.repoName);
|
|
128
195
|
const make = seams.makeExecutor ?? makeLocalMergeExecutor;
|
|
129
|
-
const executor = make({
|
|
196
|
+
const executor = make({
|
|
197
|
+
method: fields.method,
|
|
198
|
+
ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
|
|
199
|
+
ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
|
|
200
|
+
}, seams.localMergeDeps);
|
|
130
201
|
let response;
|
|
131
202
|
try {
|
|
132
203
|
response = await executor(seams.access, request);
|
|
@@ -146,10 +217,13 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
146
217
|
}
|
|
147
218
|
return { ok: false, failure: buildMergeJobFailure(response) };
|
|
148
219
|
}
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Build the default local-merge deps for production wiring (no real I/O here).
|
|
222
|
+
* An optional {@link AbortSignal} is threaded through so the job-runner's overall
|
|
223
|
+
* merge timeout can cancel in-flight local `gh` subprocess work. The signal is
|
|
224
|
+
* NOT a credential and never reaches `gh` argv/env — the local `gh` auth model is
|
|
225
|
+
* unchanged (no App token is ever placed here).
|
|
226
|
+
*/
|
|
227
|
+
export function buildDefaultMergeLocalDeps(deps, signal) {
|
|
228
|
+
return { env: deps.env, signal };
|
|
155
229
|
}
|
|
@@ -7,19 +7,41 @@
|
|
|
7
7
|
* advisory pre-push suite) and returns before it completes. Without this check
|
|
8
8
|
* the executor still reports `clean_exit`/`succeeded`, and the gap is only ever
|
|
9
9
|
* caught by the reconciler's 3h watchdog. `validateWorkerFinalization` converts
|
|
10
|
-
* that condition into an explicit executor failure instead, using
|
|
11
|
-
* `git ls-remote` check (no Bridge API credentials, no conductor identity)
|
|
10
|
+
* that condition into an explicit executor failure instead, using an authoritative
|
|
11
|
+
* local `git ls-remote` check (no Bridge API credentials, no conductor identity)
|
|
12
|
+
* that reads the real origin tip — never a stale local `origin/<branch>` tracking
|
|
13
|
+
* ref. That lookup is optionally repeated a few times with a short bounded delay
|
|
14
|
+
* before reporting `WorkerFinalizationMissingRemoteBranchAndPr`, so a recovery
|
|
15
|
+
* job whose push has landed but whose remote visibility settles just after the
|
|
16
|
+
* worker exits is not spuriously failed (BAPI-566 Bug A).
|
|
12
17
|
*
|
|
13
18
|
* For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
|
|
14
19
|
* BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
|
|
15
20
|
* "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
|
|
16
21
|
* not proof THIS session's commit landed; a stale pre-existing branch would pass
|
|
17
|
-
* an existence-only check. The check instead compares the origin
|
|
18
|
-
* against the worker's own HEAD commit (`headSha`, from git
|
|
19
|
-
* that comparison is available.
|
|
22
|
+
* an existence-only check. The check instead compares the authoritative origin
|
|
23
|
+
* branch tip SHA against the worker's own HEAD commit (`headSha`, from git
|
|
24
|
+
* telemetry) whenever that comparison is available.
|
|
20
25
|
*/
|
|
21
26
|
import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
|
|
22
27
|
import { isImplementationStyleJobType } from "./job-types.js";
|
|
28
|
+
/** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
|
|
29
|
+
const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
|
|
30
|
+
const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
|
|
31
|
+
/** Real-timer sleep; overridable via the `sleep` seam for deterministic tests. */
|
|
32
|
+
function sleepMs(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
function normalizeAttempts(value) {
|
|
36
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
37
|
+
? value
|
|
38
|
+
: DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS;
|
|
39
|
+
}
|
|
40
|
+
function normalizeRetryDelay(value) {
|
|
41
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
42
|
+
? Math.floor(value)
|
|
43
|
+
: DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS;
|
|
44
|
+
}
|
|
23
45
|
function extractPrUrl(result) {
|
|
24
46
|
const raw = result.pr_url;
|
|
25
47
|
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
|
|
@@ -28,13 +50,66 @@ function extractPrUrl(result) {
|
|
|
28
50
|
function normalizeBranchRef(branch) {
|
|
29
51
|
return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
|
|
30
52
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Scan `git ls-remote` stdout and return the SHA for EXACTLY `expectedRef`.
|
|
55
|
+
* `ls-remote` lines are `<sha>\t<ref>`; only an exact ref match counts, so a
|
|
56
|
+
* prefix/suffix/child ref (`…-old`, `…/retry`) is never accepted. The returned
|
|
57
|
+
* SHA is trimmed and lower-cased so callers compare against a normalized worker
|
|
58
|
+
* HEAD. Raw stderr and credentials are never touched here.
|
|
59
|
+
*/
|
|
60
|
+
function parseLsRemoteHeadSha(stdout, expectedRef) {
|
|
61
|
+
for (const line of stdout.split("\n")) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
const parts = trimmed.split(/\s+/);
|
|
66
|
+
if (parts.length < 2)
|
|
67
|
+
continue;
|
|
68
|
+
const [sha, ref] = parts;
|
|
69
|
+
if (ref === expectedRef) {
|
|
70
|
+
const normalized = sha.trim().toLowerCase();
|
|
71
|
+
return normalized.length > 0 ? normalized : null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the origin branch's authoritative current tip SHA (normalized), or
|
|
78
|
+
* `null` if the ref is absent. Uses `git ls-remote` against the exact normalized
|
|
79
|
+
* ref — never a local `origin/<branch>` remote-tracking ref, which can be stale
|
|
80
|
+
* on a recovery worktree. A non-zero exit is treated as "branch not found"
|
|
81
|
+
* without copying raw stderr into diagnostics.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
|
|
84
|
+
const normalizedRef = normalizeBranchRef(branch);
|
|
85
|
+
const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], { cwd: worktreePath });
|
|
34
86
|
if (result.exitCode !== 0)
|
|
35
87
|
return null;
|
|
36
|
-
|
|
37
|
-
|
|
88
|
+
return parseLsRemoteHeadSha(result.stdout, normalizedRef);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Repeatedly resolve the authoritative origin tip until it either satisfies
|
|
92
|
+
* finalization or the bounded retry budget is exhausted. Stops early (no further
|
|
93
|
+
* lookups) when the origin tip matches the worker HEAD, or when the branch exists
|
|
94
|
+
* and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
|
|
95
|
+
* when the tip is `null` (branch not yet visible) or, with a known HEAD, does not
|
|
96
|
+
* yet match. Returns the LAST authoritative observation — the caller decides
|
|
97
|
+
* loud-failure from that final value, so true negatives are preserved.
|
|
98
|
+
*/
|
|
99
|
+
async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
|
|
100
|
+
const total = Math.max(1, attempts);
|
|
101
|
+
let remoteSha = null;
|
|
102
|
+
for (let attempt = 0; attempt < total; attempt++) {
|
|
103
|
+
remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
|
|
104
|
+
if (remoteSha !== null) {
|
|
105
|
+
// Existence-only success when HEAD is unknown; exact-match success otherwise.
|
|
106
|
+
if (!trimmedHeadSha || remoteSha === trimmedHeadSha)
|
|
107
|
+
return remoteSha;
|
|
108
|
+
}
|
|
109
|
+
if (attempt < total - 1)
|
|
110
|
+
await sleep(retryDelayMs);
|
|
111
|
+
}
|
|
112
|
+
return remoteSha;
|
|
38
113
|
}
|
|
39
114
|
function missingBranchAndPrFailure(job, detail) {
|
|
40
115
|
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
@@ -64,9 +139,21 @@ export async function validateWorkerFinalization(input) {
|
|
|
64
139
|
failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
|
|
65
140
|
};
|
|
66
141
|
}
|
|
142
|
+
// Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
|
|
143
|
+
// so the ref merely existing is not proof this session's commit landed. When
|
|
144
|
+
// the worker's own HEAD is known, require the authoritative origin tip to match
|
|
145
|
+
// it exactly — a stale pre-existing branch (this session's push never happened)
|
|
146
|
+
// fails loud instead of passing. When HEAD is unresolved (degraded telemetry),
|
|
147
|
+
// fall back to the existence-only check rather than inventing a new failure
|
|
148
|
+
// mode. The authoritative lookup is retried a few times so a push that settles
|
|
149
|
+
// just after the worker exits still finalizes (BAPI-566 Bug A).
|
|
150
|
+
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
|
|
151
|
+
const attempts = normalizeAttempts(input.originResolveAttempts);
|
|
152
|
+
const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
|
|
153
|
+
const sleep = input.sleep ?? sleepMs;
|
|
67
154
|
let remoteSha;
|
|
68
155
|
try {
|
|
69
|
-
remoteSha = await
|
|
156
|
+
remoteSha = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
|
|
70
157
|
}
|
|
71
158
|
catch (err) {
|
|
72
159
|
return {
|
|
@@ -80,13 +167,6 @@ export async function validateWorkerFinalization(input) {
|
|
|
80
167
|
failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
|
|
81
168
|
};
|
|
82
169
|
}
|
|
83
|
-
// Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
|
|
84
|
-
// so the ref merely existing is not proof this session's commit landed. When
|
|
85
|
-
// the worker's own HEAD is known, require the origin tip to match it exactly —
|
|
86
|
-
// a stale pre-existing branch (this session's push never happened) fails loud
|
|
87
|
-
// instead of passing. When HEAD is unresolved (degraded telemetry), fall back
|
|
88
|
-
// to the existence-only check rather than inventing a new failure mode.
|
|
89
|
-
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim() : "";
|
|
90
170
|
if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
|
|
91
171
|
return {
|
|
92
172
|
ok: false,
|