@bridge_gpt/mcp-server 0.2.12 → 0.2.14
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/CONDUCTOR.md +131 -0
- package/README.md +125 -103
- 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 +3 -2
|
@@ -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);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side (local) conductor merge executor (F4).
|
|
3
|
+
*
|
|
4
|
+
* Performs the GitHub merge in the epic-tick process using the agent's OWN
|
|
5
|
+
* granted `gh` credentials, instead of the hosted Bridge API merge route (which
|
|
6
|
+
* would require the backend to hold global GitHub write scope — the reason a
|
|
7
|
+
* smoke-test merge failed with `provider_unauthorized`). Opt-in only: the runtime
|
|
8
|
+
* selects this executor when `policy_json.local_merge.enabled === true`.
|
|
9
|
+
*
|
|
10
|
+
* It mirrors the backend merge guard's decision order — approval precondition →
|
|
11
|
+
* re-read PR head (drift / not-open guard) → revalidate required CI for the EXACT
|
|
12
|
+
* head SHA → provider merge — and returns the SAME `merge.*` ledger events the
|
|
13
|
+
* backend route returns, so the rest of {@link processGateMetMerge} is unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Subprocess safety: argument arrays (never a shell string), a validated numeric
|
|
16
|
+
* PR number, a merge method from a fixed allowlist, `GH_PROMPT_DISABLED` to avoid
|
|
17
|
+
* interactive hangs, and no token/secret ever logged. Default OFF everywhere.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "child_process";
|
|
20
|
+
import { pollCiChecksForCommit, } from "./bridge-api-client.js";
|
|
21
|
+
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
22
|
+
/**
|
|
23
|
+
* Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
|
|
24
|
+
* stateless process with no separate scheduler thread — a `gh` call that hangs
|
|
25
|
+
* (network stall, an auth prompt that slips past GH_PROMPT_DISABLED) would block
|
|
26
|
+
* the Node event loop indefinitely, freezing the whole tick. Killing at 60s
|
|
27
|
+
* yields a `timedOut` result the executor maps to a distinct `*_timeout` reason.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
30
|
+
/** Coerce an untrusted method value to a safe allowlisted method (default squash). */
|
|
31
|
+
export function resolveLocalMergeMethod(value) {
|
|
32
|
+
return typeof value === "string" && MERGE_METHODS.has(value)
|
|
33
|
+
? value
|
|
34
|
+
: "squash";
|
|
35
|
+
}
|
|
36
|
+
function defaultRunCommand(cmd, args, env) {
|
|
37
|
+
const result = spawnSync(cmd, args, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
env: { ...process.env, ...env },
|
|
40
|
+
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
|
|
41
|
+
});
|
|
42
|
+
// On timeout spawnSync kills the child (status: null, signal: "SIGTERM") and
|
|
43
|
+
// sets `error.code === "ETIMEDOUT"`. Surface that as a first-class flag.
|
|
44
|
+
const timedOut = result.error?.code === "ETIMEDOUT" ||
|
|
45
|
+
result.signal === "SIGTERM";
|
|
46
|
+
return {
|
|
47
|
+
status: result.status,
|
|
48
|
+
stdout: result.stdout ?? "",
|
|
49
|
+
stderr: result.stderr ?? "",
|
|
50
|
+
timedOut,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function buildResponse(request, status, reason, terminal, ledgerEvents) {
|
|
54
|
+
return {
|
|
55
|
+
action_key: request.action_key,
|
|
56
|
+
repo_name: request.repo_name,
|
|
57
|
+
pr_number: request.pr_number,
|
|
58
|
+
expected_head_sha: request.expected_head_sha,
|
|
59
|
+
status,
|
|
60
|
+
reason,
|
|
61
|
+
terminal,
|
|
62
|
+
ledger_events: ledgerEvents,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether every required CI check is green for the polled head SHA. When
|
|
67
|
+
* the gate lists no required checks, fall back to the poll's `all_passed` flag.
|
|
68
|
+
* Defensive against the poll response's exact shape: a check counts as green if
|
|
69
|
+
* any of `conclusion==="success"`, `status==="success"`, `green===true`, or
|
|
70
|
+
* `bucket==="pass"`.
|
|
71
|
+
*/
|
|
72
|
+
export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
73
|
+
if (pollResponse === null || typeof pollResponse !== "object")
|
|
74
|
+
return false;
|
|
75
|
+
let obj = pollResponse;
|
|
76
|
+
// pollCiChecksForCommit returns the `{available, reason, action, detail}`
|
|
77
|
+
// envelope; checks/all_passed live under `detail`. Unwrap it (envelope-
|
|
78
|
+
// tolerant, mirroring normalizeCiSnapshot) — reading the top level alone made
|
|
79
|
+
// every required check look missing → merge always failed ci_not_green.
|
|
80
|
+
const maybeDetail = obj.detail;
|
|
81
|
+
if (maybeDetail &&
|
|
82
|
+
typeof maybeDetail === "object" &&
|
|
83
|
+
(Array.isArray(maybeDetail.checks) ||
|
|
84
|
+
"all_passed" in maybeDetail)) {
|
|
85
|
+
obj = maybeDetail;
|
|
86
|
+
}
|
|
87
|
+
const rawChecks = Array.isArray(obj.checks) ? obj.checks : [];
|
|
88
|
+
if (requiredChecks.length === 0) {
|
|
89
|
+
return obj.all_passed === true;
|
|
90
|
+
}
|
|
91
|
+
const byName = new Map();
|
|
92
|
+
for (const c of rawChecks) {
|
|
93
|
+
const name = typeof c.name === "string" ? c.name : null;
|
|
94
|
+
if (name !== null)
|
|
95
|
+
byName.set(name, c);
|
|
96
|
+
}
|
|
97
|
+
const isGreen = (c) => {
|
|
98
|
+
if (!c)
|
|
99
|
+
return false;
|
|
100
|
+
const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
|
|
101
|
+
const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
|
|
102
|
+
const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
|
|
103
|
+
return c.green === true || conclusion === "success" || status === "success" || bucket === "pass";
|
|
104
|
+
};
|
|
105
|
+
return requiredChecks.every((name) => isGreen(byName.get(name)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build a `(access, request) => Promise<ConductorMergeResponse>` that performs the
|
|
109
|
+
* merge locally via `gh`. Drop-in replacement for `mergePullRequestForGate` used
|
|
110
|
+
* by {@link processGateMetMerge}'s `merge` seam.
|
|
111
|
+
*/
|
|
112
|
+
export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
113
|
+
const method = resolveLocalMergeMethod(options.method);
|
|
114
|
+
const run = deps.runCommand ?? defaultRunCommand;
|
|
115
|
+
const pollCi = deps.pollCi ?? pollCiChecksForCommit;
|
|
116
|
+
// Non-interactive + no-noise env for gh; the caller's env (incl. any token)
|
|
117
|
+
// is overlaid first, then the safety pins.
|
|
118
|
+
const ghEnv = {
|
|
119
|
+
...deps.env,
|
|
120
|
+
GH_PROMPT_DISABLED: "1",
|
|
121
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
122
|
+
};
|
|
123
|
+
return async (access, request) => {
|
|
124
|
+
const pr = request.pr_number;
|
|
125
|
+
const expectedSha = request.expected_head_sha;
|
|
126
|
+
const requiredChecks = request.gate?.required_checks ?? [];
|
|
127
|
+
const baseDetails = {
|
|
128
|
+
action_key: request.action_key,
|
|
129
|
+
repo: request.repo_name,
|
|
130
|
+
pr_number: pr,
|
|
131
|
+
expected_head_sha: expectedSha,
|
|
132
|
+
merge_method: method,
|
|
133
|
+
executor: "local",
|
|
134
|
+
};
|
|
135
|
+
const fail = (reason) => buildResponse(request, "failed", reason, false, [
|
|
136
|
+
{ type: "merge.failed", status: "failed", reason, details: baseDetails },
|
|
137
|
+
]);
|
|
138
|
+
// 1. Approval precondition — never merge when approval is required.
|
|
139
|
+
if (options.approvalRequired) {
|
|
140
|
+
return buildResponse(request, "pending_approval", "local_merge_approval_required", false, [
|
|
141
|
+
{
|
|
142
|
+
type: "merge.pending_approval",
|
|
143
|
+
status: "pending_approval",
|
|
144
|
+
reason: "local_merge_approval_required",
|
|
145
|
+
details: baseDetails,
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
// 2. Re-read PR head + open-state (head-drift / closed guard).
|
|
150
|
+
const view = run("gh", ["pr", "view", String(pr), "--json", "headRefOid,state"], ghEnv);
|
|
151
|
+
if (view.timedOut)
|
|
152
|
+
return fail("gh_pr_view_timeout");
|
|
153
|
+
if (view.status !== 0)
|
|
154
|
+
return fail("gh_pr_view_failed");
|
|
155
|
+
let headOid;
|
|
156
|
+
let state;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(view.stdout);
|
|
159
|
+
headOid = parsed.headRefOid;
|
|
160
|
+
state = parsed.state;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return fail("gh_pr_view_unparseable");
|
|
164
|
+
}
|
|
165
|
+
if (typeof state === "string" && state.toUpperCase() !== "OPEN")
|
|
166
|
+
return fail("pr_not_open");
|
|
167
|
+
if (typeof headOid !== "string" || headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
168
|
+
return fail("head_drift");
|
|
169
|
+
}
|
|
170
|
+
// 3. Revalidate required CI green for the exact head SHA.
|
|
171
|
+
let pollResponse;
|
|
172
|
+
try {
|
|
173
|
+
pollResponse = await pollCi(access, expectedSha);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return fail("ci_poll_failed");
|
|
177
|
+
}
|
|
178
|
+
if (!allRequiredChecksGreen(pollResponse, requiredChecks))
|
|
179
|
+
return fail("ci_not_green");
|
|
180
|
+
// 4. Provider merge.
|
|
181
|
+
const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
|
|
182
|
+
if (merge.status !== 0) {
|
|
183
|
+
const mergeFailReason = merge.timedOut ? "gh_merge_timeout" : "gh_merge_failed";
|
|
184
|
+
return buildResponse(request, "failed", mergeFailReason, false, [
|
|
185
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
186
|
+
{ type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails },
|
|
187
|
+
]);
|
|
188
|
+
}
|
|
189
|
+
// 5. Best-effort resolve the squash/merge commit SHA for the audit trail.
|
|
190
|
+
let mergeCommitSha;
|
|
191
|
+
const post = run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
|
|
192
|
+
if (post.status === 0) {
|
|
193
|
+
try {
|
|
194
|
+
const oid = JSON.parse(post.stdout)?.mergeCommit;
|
|
195
|
+
if (oid && typeof oid === "object" && typeof oid.oid === "string") {
|
|
196
|
+
mergeCommitSha = oid.oid;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* best-effort only */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const succeededDetails = {
|
|
204
|
+
...baseDetails,
|
|
205
|
+
...(mergeCommitSha ? { merge_commit_sha: mergeCommitSha } : {}),
|
|
206
|
+
};
|
|
207
|
+
return buildResponse(request, "succeeded", null, true, [
|
|
208
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
209
|
+
{ type: "merge.succeeded", status: "succeeded", details: succeededDetails },
|
|
210
|
+
]);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* check. All event types come from the existing conductor taxonomy and use only
|
|
10
10
|
* allowlisted top-level data keys.
|
|
11
11
|
*/
|
|
12
|
-
import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
|
|
12
|
+
import { GIT_CI_PRODUCER, REVIEW_STATE, } from "./git-ci-types.js";
|
|
13
13
|
import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
|
|
14
14
|
import { observeReviewWithResolved } from "./pr-review-producer.js";
|
|
15
15
|
import { fetchEffectiveSupervisorSetup, fetchActiveEpicRuns, fetchEpicRunState, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
|
|
@@ -455,7 +455,17 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
455
455
|
rawConfig = undefined;
|
|
456
456
|
}
|
|
457
457
|
const gateConfig = parseDoneGateConfig(rawConfig);
|
|
458
|
-
|
|
458
|
+
// F6: this poll-driven path observes CI only — it has NO review snapshot
|
|
459
|
+
// (unlike observeWithResolved, which fetches one via observeReviewWithResolved).
|
|
460
|
+
// A composite gate with any review_state condition can therefore never be
|
|
461
|
+
// satisfied here and would only fail closed silently. Rather than pretend to
|
|
462
|
+
// be a gate path, refuse to evaluate review-gated configs and defer to
|
|
463
|
+
// `wait_for_done_gate` (the complete observer). CI-only gates still emit here.
|
|
464
|
+
const requiresReview = gateConfig.conditions.some((c) => c.type === REVIEW_STATE);
|
|
465
|
+
if (gateConfig.enabled && gateConfig.valid && requiresReview) {
|
|
466
|
+
result.reason = "review-gated config: gate.met deferred to wait_for_done_gate (poll path is CI-only)";
|
|
467
|
+
}
|
|
468
|
+
if (gateConfig.enabled && gateConfig.valid && !requiresReview) {
|
|
459
469
|
const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
|
|
460
470
|
if (evaluation.met) {
|
|
461
471
|
result.gate_met = true;
|
package/build/conductor/store.js
CHANGED
|
@@ -180,11 +180,14 @@ export function resolveConductorStoreConfig(env = process.env) {
|
|
|
180
180
|
* event type to the same CHECK vocabulary. Bumped to 6 in BAPI-445 because the
|
|
181
181
|
* pre-implementation spec re-review verdict feature adds the `spec_review.passed`
|
|
182
182
|
* and `spec_review.changes_requested` event types to the same `events.type`
|
|
183
|
-
* CHECK vocabulary.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
183
|
+
* CHECK vocabulary. Bumped to 7 because the durable parse-after-merge fold adds
|
|
184
|
+
* the `parse.triggered` event type to the same `events.type` CHECK vocabulary
|
|
185
|
+
* (replacing the in-memory parse-wait map so stateless epic-tick invocations can
|
|
186
|
+
* fold a merged ticket to `done`). Older ledgers stamped at a lower version are
|
|
187
|
+
* rebuilt by {@link migrateConductorSchemaIfNeeded} so their CHECK clause accepts
|
|
188
|
+
* the current taxonomy.
|
|
186
189
|
*/
|
|
187
|
-
export const CURRENT_CONDUCTOR_SCHEMA_VERSION =
|
|
190
|
+
export const CURRENT_CONDUCTOR_SCHEMA_VERSION = 7;
|
|
188
191
|
/** Render the taxonomy `CHECK (type IN (...))` clause from the single source of truth. */
|
|
189
192
|
function buildTypeCheckClause() {
|
|
190
193
|
const list = SEMANTIC_EVENT_TYPES.map((t) => `'${t}'`).join(", ");
|
|
@@ -43,6 +43,10 @@ export const SEMANTIC_EVENT_TYPES = [
|
|
|
43
43
|
// mis-folds a spec-review outcome as the implementation PR's review state.
|
|
44
44
|
"spec_review.passed",
|
|
45
45
|
"spec_review.changes_requested",
|
|
46
|
+
// Durable parse-after-merge marker. Emitted by epic-tick when it triggers a
|
|
47
|
+
// post-merge repository re-index, so the (stateless) reconcile loop can fold a
|
|
48
|
+
// merged ticket to `done` from the ledger instead of an in-memory wait map.
|
|
49
|
+
"parse.triggered",
|
|
46
50
|
];
|
|
47
51
|
/**
|
|
48
52
|
* Type guard: returns `true` only when `value` is one of the exact taxonomy
|