@bridge_gpt/mcp-server 0.2.18 → 0.2.19
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 +75 -0
- package/README.md +2 -2
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +1 -0
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +97 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +11222 -8544
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/public/css/main.min.css +39 -0
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -24,6 +24,7 @@ import { observePrCiOnce } from "./pr-ci-producer.js";
|
|
|
24
24
|
import { runGhCommand } from "./pr-discovery.js";
|
|
25
25
|
import { isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
26
26
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
|
|
27
|
+
import { getHeadSha, getPrNumber } from "./event-accessors.js";
|
|
27
28
|
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
28
29
|
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
29
30
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
@@ -130,6 +131,200 @@ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
|
|
|
130
131
|
}
|
|
131
132
|
return null;
|
|
132
133
|
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Dispatched/running done-gate backstop (BAPI-525 Change B)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
/** Conservative default quiescence window (ticks) for the dispatched backstop. */
|
|
138
|
+
const DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS = 3;
|
|
139
|
+
/** The conservative default (disabled) dispatched-backstop policy. */
|
|
140
|
+
export const DEFAULT_DISPATCHED_BACKSTOP_POLICY = {
|
|
141
|
+
enabled: false,
|
|
142
|
+
quiescence_ticks: DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS,
|
|
143
|
+
quiescence_ms: null,
|
|
144
|
+
};
|
|
145
|
+
/** Accept only a positive integer; every other shape (0, negative, float, string, NaN) → null. */
|
|
146
|
+
function parsePositiveInt(value) {
|
|
147
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the dispatched-backstop policy from an epic run's `policy_json`.
|
|
151
|
+
* Fail-closed to {@link DEFAULT_DISPATCHED_BACKSTOP_POLICY} on any missing/malformed
|
|
152
|
+
* shape. Only a literal boolean `true` enables the path; `quiescence_ticks` /
|
|
153
|
+
* `quiescence_ms` accept only positive integers and otherwise fall back to the
|
|
154
|
+
* conservative default (ticks) / `null` (ms). Mirrors the defensive style used for
|
|
155
|
+
* `policy_json.post_action_waits.parse_after_merge`.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveDispatchedBackstopPolicy(policyJson) {
|
|
158
|
+
const doneGate = policyJson && typeof policyJson === "object" && !Array.isArray(policyJson)
|
|
159
|
+
? policyJson.done_gate
|
|
160
|
+
: undefined;
|
|
161
|
+
const cfg = doneGate && typeof doneGate === "object" && !Array.isArray(doneGate)
|
|
162
|
+
? doneGate.dispatched_backstop
|
|
163
|
+
: undefined;
|
|
164
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
165
|
+
return { ...DEFAULT_DISPATCHED_BACKSTOP_POLICY };
|
|
166
|
+
}
|
|
167
|
+
const record = cfg;
|
|
168
|
+
return {
|
|
169
|
+
enabled: record.enabled === true,
|
|
170
|
+
quiescence_ticks: parsePositiveInt(record.quiescence_ticks) ?? DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS,
|
|
171
|
+
quiescence_ms: parsePositiveInt(record.quiescence_ms),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Reuse the single active-worker status set as the backstop admission set. */
|
|
175
|
+
export function isActiveWorkerStatus(status) {
|
|
176
|
+
return ACTIVE_WORKER_STATUSES.has(status);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The base done-gate statuses (`ready_for_review`/`blocked`) whose behavior is
|
|
180
|
+
* unchanged by BAPI-525 — no quiescence gate is applied to them.
|
|
181
|
+
*/
|
|
182
|
+
export function isBaseDoneGateStatus(status) {
|
|
183
|
+
return status === "ready_for_review" || status === "blocked";
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Head-changing event families used to compute stable-head quiescence. `getHeadSha`
|
|
187
|
+
* reads the canonical `data.details.head_sha` regardless of the typed details
|
|
188
|
+
* family, so `git.commit_created` (details-less in the accessor map) still yields a
|
|
189
|
+
* head when the raw envelope carries one.
|
|
190
|
+
*/
|
|
191
|
+
const HEAD_CHANGING_EVENT_TYPES = new Set([
|
|
192
|
+
"git.pr_opened",
|
|
193
|
+
"git.commit_created",
|
|
194
|
+
"ci.passed",
|
|
195
|
+
"review.passed",
|
|
196
|
+
"gate.met",
|
|
197
|
+
]);
|
|
198
|
+
/**
|
|
199
|
+
* Build a local-first PR binding resolver for the dispatched backstop (BAPI-525
|
|
200
|
+
* Change B, Requirement 5). Resolution order per ticket:
|
|
201
|
+
* 1. dispatch/run-state PR binding — SKIPPED: `EpicDispatch`/`EpicTicketStatus`
|
|
202
|
+
* carry no PR number/head SHA and `pr_bindings` is a legacy always-null seam,
|
|
203
|
+
* so there is no durable PR binding on run state to consult (documented here so
|
|
204
|
+
* a future state-model addition knows to wire it in first).
|
|
205
|
+
* 2. the newest local ledger event (by `seq`) carrying BOTH a valid `pr_number`
|
|
206
|
+
* and `head_sha` (e.g. a `gate.met`), read through the typed `getPrNumber` /
|
|
207
|
+
* `getHeadSha` accessors and normalized fail-closed.
|
|
208
|
+
* 3. a `gh pr view feature/<KEY>` lookup, with NEGATIVE results cached per tick so
|
|
209
|
+
* a ticket with no open PR is never polled twice in the same tick.
|
|
210
|
+
* A local binding carries `mergeable`/`mergeStateStatus: null` (unknown without the
|
|
211
|
+
* gh call); a conflict check treats `null` as non-conflicting.
|
|
212
|
+
*/
|
|
213
|
+
export function buildTicketPrBindingResolver(deps) {
|
|
214
|
+
// Precompute ticket_key -> newest local (pr_number, head_sha) binding.
|
|
215
|
+
const localByTicket = new Map();
|
|
216
|
+
for (const ev of deps.events) {
|
|
217
|
+
const prNumber = getPrNumber(ev);
|
|
218
|
+
const headSha = getHeadSha(ev);
|
|
219
|
+
if (prNumber === null || headSha === null)
|
|
220
|
+
continue;
|
|
221
|
+
let ticketKey = null;
|
|
222
|
+
if (typeof ev.run_id === "string" && deps.resolveTicketForRunId) {
|
|
223
|
+
ticketKey = deps.resolveTicketForRunId(ev.run_id);
|
|
224
|
+
}
|
|
225
|
+
if (!ticketKey && typeof ev.subject === "string" && ev.subject.trim().length > 0) {
|
|
226
|
+
ticketKey = ev.subject;
|
|
227
|
+
}
|
|
228
|
+
if (!ticketKey)
|
|
229
|
+
continue;
|
|
230
|
+
const prev = localByTicket.get(ticketKey);
|
|
231
|
+
if (!prev || ev.seq >= prev.seq) {
|
|
232
|
+
localByTicket.set(ticketKey, {
|
|
233
|
+
seq: ev.seq,
|
|
234
|
+
binding: {
|
|
235
|
+
prNumber,
|
|
236
|
+
headSha: headSha.toLowerCase(),
|
|
237
|
+
mergeable: null,
|
|
238
|
+
mergeStateStatus: null,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Per-tick gh cache: memoize BOTH hits and misses so a missing PR is looked up at
|
|
244
|
+
// most once per ticket per tick (Requirement 5 — no gh/CI thrash on dispatched).
|
|
245
|
+
const ghCache = new Map();
|
|
246
|
+
return (ticketKey) => {
|
|
247
|
+
const local = localByTicket.get(ticketKey);
|
|
248
|
+
if (local)
|
|
249
|
+
return local.binding;
|
|
250
|
+
if (ghCache.has(ticketKey))
|
|
251
|
+
return ghCache.get(ticketKey) ?? null;
|
|
252
|
+
const gh = deps.resolveFromGh(ticketKey);
|
|
253
|
+
ghCache.set(ticketKey, gh);
|
|
254
|
+
return gh;
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build the active-ticket quiescence resolver (BAPI-525 Change B, Requirement 6).
|
|
259
|
+
* A `dispatched`/`running` ticket is folded toward `ready_for_review` ONLY when its
|
|
260
|
+
* PR-bound completion evidence (resolved by the caller) is accompanied by a
|
|
261
|
+
* quiescence signal — NEVER on green-PR evidence alone, because workers are
|
|
262
|
+
* interactive and may still be pushing follow-up commits:
|
|
263
|
+
*
|
|
264
|
+
* - an explicit implementation `run.stopped` for the ticket's impl run, OR
|
|
265
|
+
* - a head SHA that MATCHES the candidate PR head AND has been stable for at least
|
|
266
|
+
* `policy.quiescence_ms` since the latest head-changing ledger event.
|
|
267
|
+
*
|
|
268
|
+
* `quiescence_ticks` is parsed and carried on the policy but is NOT used here: the
|
|
269
|
+
* conductor has no durable per-tick marker to count "N consecutive ticks" against,
|
|
270
|
+
* and tick stability MUST NOT be faked from process memory (it would not survive a
|
|
271
|
+
* restart). When only `quiescence_ticks` is configured (no `quiescence_ms`), the
|
|
272
|
+
* stable-head path cannot prove quiescence and only `run.stopped` folds the ticket.
|
|
273
|
+
* Worker MCP heartbeats (`message.delivered`/`message.acked`) are deliberately NOT
|
|
274
|
+
* used as the reliable primitive — that channel is exactly what fails in Issue 1.
|
|
275
|
+
*/
|
|
276
|
+
export function buildActiveTicketQuiescenceResolver(deps) {
|
|
277
|
+
return (ticketKey, prBinding) => {
|
|
278
|
+
if (!deps.policy.enabled)
|
|
279
|
+
return { quiescent: false, reason: "policy_disabled" };
|
|
280
|
+
const implRunId = deps.resolveImplRunId(ticketKey);
|
|
281
|
+
// Select the ticket's implementation-run events. Prefer the impl run_id (which
|
|
282
|
+
// excludes any `:review` run); fall back to subject match when no run is known.
|
|
283
|
+
const matchesTicket = (ev) => implRunId !== null
|
|
284
|
+
? ev.run_id === implRunId
|
|
285
|
+
: ev.subject === ticketKey;
|
|
286
|
+
// Explicit completion evidence: an implementation `run.stopped`.
|
|
287
|
+
for (const ev of deps.events) {
|
|
288
|
+
if (ev.type === "run.stopped" && matchesTicket(ev)) {
|
|
289
|
+
return { quiescent: true, reason: "run.stopped" };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Stable-head evidence: latest head-changing event for the impl run.
|
|
293
|
+
let latest = null;
|
|
294
|
+
for (const ev of deps.events) {
|
|
295
|
+
if (!HEAD_CHANGING_EVENT_TYPES.has(ev.type) || !matchesTicket(ev))
|
|
296
|
+
continue;
|
|
297
|
+
const headSha = getHeadSha(ev);
|
|
298
|
+
if (headSha === null)
|
|
299
|
+
continue;
|
|
300
|
+
const timeMs = typeof ev.time === "string" ? Date.parse(ev.time) : NaN;
|
|
301
|
+
if (Number.isNaN(timeMs))
|
|
302
|
+
continue;
|
|
303
|
+
if (!latest || ev.seq >= latest.seq) {
|
|
304
|
+
latest = { headSha: headSha.toLowerCase(), timeMs, seq: ev.seq };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!latest)
|
|
308
|
+
return { quiescent: false, reason: "no_signal" };
|
|
309
|
+
if (latest.headSha !== prBinding.headSha.toLowerCase()) {
|
|
310
|
+
return { quiescent: false, reason: "unstable_head", last_head_event_time: latest.timeMs };
|
|
311
|
+
}
|
|
312
|
+
// Head matches the candidate PR head. Prove stability via the ms window only.
|
|
313
|
+
if (deps.policy.quiescence_ms === null) {
|
|
314
|
+
return { quiescent: false, reason: "no_signal", last_head_event_time: latest.timeMs };
|
|
315
|
+
}
|
|
316
|
+
const stableMs = deps.nowMs - latest.timeMs;
|
|
317
|
+
if (stableMs >= deps.policy.quiescence_ms) {
|
|
318
|
+
return {
|
|
319
|
+
quiescent: true,
|
|
320
|
+
reason: "stable_head",
|
|
321
|
+
stable_ms: stableMs,
|
|
322
|
+
last_head_event_time: latest.timeMs,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { quiescent: false, reason: "unstable_head", last_head_event_time: latest.timeMs };
|
|
326
|
+
};
|
|
327
|
+
}
|
|
133
328
|
/**
|
|
134
329
|
* For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
|
|
135
330
|
* live PR head has advanced past the head that blocked it — ask the conductor to
|
|
@@ -151,14 +346,39 @@ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
|
|
|
151
346
|
* PR binding, are skipped.
|
|
152
347
|
*/
|
|
153
348
|
export async function runConductorDoneGatePass(ticketStatuses, deps) {
|
|
349
|
+
const backstopEnabled = deps.dispatchedBackstopPolicy?.enabled === true;
|
|
154
350
|
for (const [ticketKey, status] of ticketStatuses) {
|
|
155
|
-
|
|
351
|
+
// BAPI-525 Change B: admit the base statuses (ready_for_review/blocked) always;
|
|
352
|
+
// admit the active statuses (dispatched/running) ONLY when the dispatched
|
|
353
|
+
// backstop is enabled. Every other status (terminal, planned, reviewing, …) is
|
|
354
|
+
// skipped before any PR binding / CI observation, exactly as before.
|
|
355
|
+
const isActive = isActiveWorkerStatus(status);
|
|
356
|
+
if (!isBaseDoneGateStatus(status) && !(isActive && backstopEnabled))
|
|
156
357
|
continue;
|
|
157
|
-
|
|
358
|
+
// Base statuses always resolve the CURRENT head via gh-discovery (unchanged).
|
|
359
|
+
// Active statuses use the local-first resolver (BAPI-525 Change B) so a
|
|
360
|
+
// dispatched ticket with no local PR binding is not re-polled every tick.
|
|
361
|
+
const prBinding = isActive
|
|
362
|
+
? (deps.resolveActivePrBinding ?? deps.resolvePrBinding)(ticketKey)
|
|
363
|
+
: deps.resolvePrBinding(ticketKey);
|
|
158
364
|
if (prBinding === null) {
|
|
159
365
|
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
|
|
160
366
|
continue;
|
|
161
367
|
}
|
|
368
|
+
// BAPI-525 Change B: an admitted active ticket is folded ONLY when quiescent
|
|
369
|
+
// (explicit run.stopped or a stable head) — never on green-PR evidence alone, so
|
|
370
|
+
// an interactive worker still pushing commits is not yanked out from under.
|
|
371
|
+
if (isActive) {
|
|
372
|
+
const quiescence = deps.resolveActiveTicketQuiescence?.(ticketKey, prBinding) ?? {
|
|
373
|
+
quiescent: false,
|
|
374
|
+
reason: "no_signal",
|
|
375
|
+
};
|
|
376
|
+
if (!quiescence.quiescent) {
|
|
377
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (${status} not quiescent: ${quiescence.reason})`);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
deps.log(`[epic-tick] done-gate backstop for ${ticketKey}: ${status} quiescent (${quiescence.reason}); evaluating done-gate at head ${prBinding.headSha}`);
|
|
381
|
+
}
|
|
162
382
|
// BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
|
|
163
383
|
if (status === "blocked") {
|
|
164
384
|
const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
|
|
@@ -175,12 +395,13 @@ export async function runConductorDoneGatePass(ticketStatuses, deps) {
|
|
|
175
395
|
`superseded by current head ${prBinding.headSha}; re-evaluating`);
|
|
176
396
|
}
|
|
177
397
|
}
|
|
178
|
-
// Stamp the ticket's run_id
|
|
179
|
-
// per-ticket env override (observePrCiOnce reads
|
|
180
|
-
// _WORKER_ID from env, env taking precedence). run_id
|
|
181
|
-
// the
|
|
182
|
-
//
|
|
183
|
-
//
|
|
398
|
+
// Stamp the ticket's run_id and (when available) worker_id onto the emitted
|
|
399
|
+
// gate.met via a per-ticket env override (observePrCiOnce reads
|
|
400
|
+
// BAPI_CONDUCTOR_RUN_ID/_WORKER_ID from env, env taking precedence). run_id
|
|
401
|
+
// correlates the fold to the ticket. worker_id is attribution ONLY: BAPI-525
|
|
402
|
+
// Change A removed the worker_id requirement from merge-identity extraction, so
|
|
403
|
+
// a missing worker_id no longer blocks the merge — the backstop stamps real
|
|
404
|
+
// attribution when it can resolve it and merges regardless when it cannot.
|
|
184
405
|
const runId = deps.resolveRunId?.(ticketKey) ?? null;
|
|
185
406
|
const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
|
|
186
407
|
// BAPI-494: a `ready_for_review` PR that is already un-mergeable
|
|
@@ -439,7 +660,6 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
439
660
|
const isParseWaitEnabled = pamConfig?.enabled === true;
|
|
440
661
|
if (isParseWaitEnabled) {
|
|
441
662
|
const maxWaitMs = typeof pamConfig?.max_wait_ms === "number" ? pamConfig.max_wait_ms : 10 * 60 * 1000;
|
|
442
|
-
const settleMs = 5000;
|
|
443
663
|
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
444
664
|
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
445
665
|
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
@@ -476,26 +696,52 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
476
696
|
e.run_id === mergeRunId &&
|
|
477
697
|
new Date(e.time).getTime() >= mergeTimeMs);
|
|
478
698
|
const elapsedMs = nowFn() - mergeTimeMs;
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
// the
|
|
483
|
-
|
|
699
|
+
// BAPI-527: enter (or hold at) the explicit parse_pending state. This
|
|
700
|
+
// replaces the old "revert to ready_for_review and hold" behavior — a
|
|
701
|
+
// merged ticket whose post-merge parse is queued/running is now DURABLY
|
|
702
|
+
// parse_pending, so the state is observable between stateless epic-tick
|
|
703
|
+
// processes. When the ticket is ALREADY durably parse_pending we hold
|
|
704
|
+
// WITHOUT a churny re-CAS (revert drops the signal, leaving the Postgres row
|
|
705
|
+
// untouched); otherwise we retag the merge.succeeded fold to parse_pending so
|
|
706
|
+
// reconcile CASes it there.
|
|
707
|
+
const holdOrEnterParsePending = () => {
|
|
708
|
+
if (currentPgStatus === "parse_pending") {
|
|
709
|
+
revertSignal();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
signal.next_status = "parse_pending";
|
|
713
|
+
observed.ticket_statuses.set(ticketKey, "parse_pending");
|
|
714
|
+
};
|
|
715
|
+
// BAPI-527: fold to `blocked` for a terminal parse failure or an exhausted
|
|
716
|
+
// wait budget. Escalates exactly once, gated on the durable Postgres status
|
|
717
|
+
// (not an in-memory flag): a later tick that finds the ticket already
|
|
718
|
+
// `blocked` drops the signal without re-escalating or re-CASing. EVERY
|
|
719
|
+
// terminal failure/timeout path routes through here, so a permanent parse
|
|
720
|
+
// failure can never silently wedge the epic at parse_pending. `reason` is a
|
|
721
|
+
// bounded, secret-free summary — never subprocess stderr/traceback.
|
|
722
|
+
const foldToBlocked = async (reason) => {
|
|
484
723
|
if (currentPgStatus === "blocked") {
|
|
485
724
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
486
725
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
487
726
|
i -= 1;
|
|
488
|
-
|
|
727
|
+
return;
|
|
489
728
|
}
|
|
490
|
-
await escalateOnce(epic_key,
|
|
729
|
+
await escalateOnce(epic_key, reason);
|
|
491
730
|
signal.next_status = "blocked";
|
|
492
731
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
493
732
|
// Let the signal remain so the reconcile pass CASes once to blocked.
|
|
733
|
+
};
|
|
734
|
+
// Budget exhaustion (measured from the durable merge.succeeded time):
|
|
735
|
+
// escalate once then block with a parse-timeout reason.
|
|
736
|
+
if (elapsedMs > maxWaitMs) {
|
|
737
|
+
await foldToBlocked(`parse-after-merge timed out (budget exhausted) for ${ticketKey}`);
|
|
494
738
|
continue;
|
|
495
739
|
}
|
|
496
740
|
if (!parseTriggeredEvent) {
|
|
497
|
-
// No durable trigger for this merge yet — fire the parse
|
|
498
|
-
// marker,
|
|
741
|
+
// No durable trigger for this merge yet — fire the parse, record the
|
|
742
|
+
// marker, and set the ticket to parse_pending. DO NOT poll parse status
|
|
743
|
+
// in the SAME tick as the trigger, so parse_pending is always observable
|
|
744
|
+
// after merge before any success/failure fold.
|
|
499
745
|
try {
|
|
500
746
|
await triggerParseFn(access);
|
|
501
747
|
emitConductorEventFn({
|
|
@@ -520,16 +766,20 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
520
766
|
run_id: mergeRunId ?? undefined,
|
|
521
767
|
commit_sha: mergeHeadSha,
|
|
522
768
|
});
|
|
523
|
-
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
769
|
+
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}; holding at parse_pending`);
|
|
770
|
+
holdOrEnterParsePending();
|
|
524
771
|
}
|
|
525
772
|
catch (err) {
|
|
526
773
|
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
527
774
|
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
775
|
+
// Transient trigger failure: hold the merge.succeeded signal for the
|
|
776
|
+
// next tick rather than folding to done/blocked.
|
|
777
|
+
revertSignal();
|
|
528
778
|
}
|
|
529
|
-
revertSignal();
|
|
530
779
|
continue;
|
|
531
780
|
}
|
|
532
|
-
// A durable parse.triggered exists for this merge — poll the
|
|
781
|
+
// A durable parse.triggered exists for this merge — poll the durable parse
|
|
782
|
+
// status surface (BAPI-527: idle | queued | in_progress | succeeded | failed).
|
|
533
783
|
let parseStatusResult;
|
|
534
784
|
try {
|
|
535
785
|
parseStatusResult = await fetchParseStatusFn(access);
|
|
@@ -537,30 +787,84 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
537
787
|
catch (err) {
|
|
538
788
|
const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
|
|
539
789
|
errorLog(`[epic-tick] parse-status check failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
540
|
-
|
|
790
|
+
// Transient status-fetch failure: hold in parse_pending for the next tick.
|
|
791
|
+
holdOrEnterParsePending();
|
|
541
792
|
continue;
|
|
542
793
|
}
|
|
543
|
-
if (parseStatusResult.status === "
|
|
544
|
-
//
|
|
545
|
-
|
|
794
|
+
if (parseStatusResult.status === "failed") {
|
|
795
|
+
// Terminal parse failure — emit a compact parse.failed marker, then fold
|
|
796
|
+
// to blocked (escalate-once). `last_error` is already a bounded, sanitized
|
|
797
|
+
// backend summary; the marker carries no stdout/stderr/stack/command.
|
|
798
|
+
emitConductorEventFn({
|
|
799
|
+
source: PARSE_WAIT_EVENT_SOURCE,
|
|
800
|
+
type: "parse.failed",
|
|
801
|
+
subject: ticketKey,
|
|
802
|
+
run_id: mergeRunId,
|
|
803
|
+
worker_id: mergeEvent.worker_id ?? null,
|
|
804
|
+
producer: PARSE_WAIT_EVENT_PRODUCER,
|
|
805
|
+
observed_via: "supervisor",
|
|
806
|
+
time: new Date(nowFn()).toISOString(),
|
|
807
|
+
data: {
|
|
808
|
+
summary: `parse-after-merge failed for ${ticketKey}`,
|
|
809
|
+
status: "failed",
|
|
810
|
+
reason: "parse_failed",
|
|
811
|
+
details: {
|
|
812
|
+
epic_key,
|
|
813
|
+
ticket_key: ticketKey,
|
|
814
|
+
...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
|
|
815
|
+
...(typeof parseStatusResult.last_error === "string"
|
|
816
|
+
? { last_error: parseStatusResult.last_error }
|
|
817
|
+
: {}),
|
|
818
|
+
...(typeof parseStatusResult.attempt_count === "number"
|
|
819
|
+
? { attempt_count: parseStatusResult.attempt_count }
|
|
820
|
+
: {}),
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
}, {
|
|
824
|
+
event_type: "parse.failed",
|
|
825
|
+
run_id: mergeRunId ?? undefined,
|
|
826
|
+
commit_sha: mergeHeadSha,
|
|
827
|
+
});
|
|
828
|
+
await foldToBlocked(`parse-after-merge failed for ${ticketKey}`);
|
|
546
829
|
continue;
|
|
547
830
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
831
|
+
if (parseStatusResult.status === "succeeded") {
|
|
832
|
+
// Terminal parse success — emit a compact parse.succeeded marker and let
|
|
833
|
+
// the original merge.succeeded signal proceed with next_status="done".
|
|
834
|
+
emitConductorEventFn({
|
|
835
|
+
source: PARSE_WAIT_EVENT_SOURCE,
|
|
836
|
+
type: "parse.succeeded",
|
|
837
|
+
subject: ticketKey,
|
|
838
|
+
run_id: mergeRunId,
|
|
839
|
+
worker_id: mergeEvent.worker_id ?? null,
|
|
840
|
+
producer: PARSE_WAIT_EVENT_PRODUCER,
|
|
841
|
+
observed_via: "supervisor",
|
|
842
|
+
time: new Date(nowFn()).toISOString(),
|
|
843
|
+
data: {
|
|
844
|
+
summary: `parse-after-merge succeeded for ${ticketKey}`,
|
|
845
|
+
status: "succeeded",
|
|
846
|
+
reason: "parse_succeeded",
|
|
847
|
+
details: {
|
|
848
|
+
epic_key,
|
|
849
|
+
ticket_key: ticketKey,
|
|
850
|
+
...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
}, {
|
|
854
|
+
event_type: "parse.succeeded",
|
|
855
|
+
run_id: mergeRunId ?? undefined,
|
|
856
|
+
commit_sha: mergeHeadSha,
|
|
857
|
+
});
|
|
858
|
+
log(`[epic-tick] parse-after-merge succeeded for ${ticketKey}; folding merge.succeeded → done`);
|
|
859
|
+
// Do NOT revert: signal.next_status stays "done" (from the merge.succeeded
|
|
860
|
+
// fold) so reconcile CASes the ticket to done.
|
|
561
861
|
continue;
|
|
562
862
|
}
|
|
563
|
-
//
|
|
863
|
+
// status is queued | in_progress | idle. All are NON-terminal for a
|
|
864
|
+
// triggered parse: queued/in_progress are self-evidently still running, and
|
|
865
|
+
// BAPI-527 treats `idle` as unknown/not-yet-terminal — NEVER fold to done
|
|
866
|
+
// from idle (only an explicit `succeeded` does that). Hold at parse_pending.
|
|
867
|
+
holdOrEnterParsePending();
|
|
564
868
|
}
|
|
565
869
|
}
|
|
566
870
|
// Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
|
|
@@ -726,8 +1030,11 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
726
1030
|
// caught per-ticket so it can never crash or block the reconcile loop, and
|
|
727
1031
|
// `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
|
|
728
1032
|
// run_id → worker_id from the ledger (first worker_id seen per run) so the
|
|
729
|
-
// conductor-emitted gate.met carries the real worker's id
|
|
730
|
-
//
|
|
1033
|
+
// conductor-emitted gate.met carries the real worker's id for ATTRIBUTION.
|
|
1034
|
+
// BAPI-525 Change A: merge eligibility no longer depends on worker_id, so an
|
|
1035
|
+
// unresolved worker maps to `null` (real attribution or nothing) — the old
|
|
1036
|
+
// synthetic `conductor:${tk}` fallback existed only to satisfy the now-removed
|
|
1037
|
+
// merge-identity presence check and is no longer needed.
|
|
731
1038
|
const runIdToWorkerId = new Map();
|
|
732
1039
|
for (const ev of localEvents) {
|
|
733
1040
|
if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
|
|
@@ -739,11 +1046,54 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
739
1046
|
const rid = resolveTicketRunId(tk);
|
|
740
1047
|
if (rid && runIdToWorkerId.has(rid))
|
|
741
1048
|
return runIdToWorkerId.get(rid);
|
|
742
|
-
return
|
|
1049
|
+
return null;
|
|
1050
|
+
};
|
|
1051
|
+
// BAPI-525 Change A: durable worker attribution for a `gate.met` that carries
|
|
1052
|
+
// no worker_id (e.g. the autonomous git-hook chain). Used ONLY to stamp the
|
|
1053
|
+
// emitted `merge.*` ledger events for audit — NEVER a merge gate. Resolves via
|
|
1054
|
+
// the event's run_id, else the event subject's implementation dispatch run,
|
|
1055
|
+
// else null (a null attribution is tolerated end-to-end).
|
|
1056
|
+
const resolveWorkerIdForGateEvent = (event, _identity) => {
|
|
1057
|
+
const directRid = typeof event.run_id === "string" && event.run_id.trim().length > 0
|
|
1058
|
+
? event.run_id
|
|
1059
|
+
: null;
|
|
1060
|
+
const rid = directRid ??
|
|
1061
|
+
(typeof event.subject === "string" && event.subject.trim().length > 0
|
|
1062
|
+
? resolveTicketRunId(event.subject)
|
|
1063
|
+
: null);
|
|
1064
|
+
if (rid && runIdToWorkerId.has(rid))
|
|
1065
|
+
return runIdToWorkerId.get(rid);
|
|
1066
|
+
return null;
|
|
743
1067
|
};
|
|
1068
|
+
// BAPI-525 Change B: resolve the per-run dispatched-backstop policy (default
|
|
1069
|
+
// OFF). When enabled, the done-gate pass ALSO admits dispatched/running
|
|
1070
|
+
// tickets, gated by quiescence. Invert the impl-dispatch maps so ledger
|
|
1071
|
+
// events can be attributed back to a ticket for local-first PR binding.
|
|
1072
|
+
const dispatchedBackstopPolicy = resolveDispatchedBackstopPolicy(epicRunState.epic_run.policy_json);
|
|
1073
|
+
const ticketForRunId = new Map();
|
|
1074
|
+
for (const [tk, rid] of ticketRunIdMap)
|
|
1075
|
+
ticketForRunId.set(rid, tk);
|
|
1076
|
+
for (const [tk, info] of latestDispatchByTicket)
|
|
1077
|
+
ticketForRunId.set(info.runId, tk);
|
|
1078
|
+
const resolveActivePrBinding = buildTicketPrBindingResolver({
|
|
1079
|
+
events: localEvents,
|
|
1080
|
+
resolveTicketForRunId: (rid) => ticketForRunId.get(rid) ?? null,
|
|
1081
|
+
resolveFromGh: resolvePrBinding,
|
|
1082
|
+
});
|
|
1083
|
+
const resolveActiveTicketQuiescence = buildActiveTicketQuiescenceResolver({
|
|
1084
|
+
events: localEvents,
|
|
1085
|
+
nowMs: nowFn(),
|
|
1086
|
+
policy: dispatchedBackstopPolicy,
|
|
1087
|
+
resolveImplRunId: resolveTicketRunId,
|
|
1088
|
+
});
|
|
744
1089
|
await runConductorDoneGatePass(observed.ticket_statuses, {
|
|
745
1090
|
observePrCi: observePrCiSeamFn,
|
|
746
1091
|
resolvePrBinding,
|
|
1092
|
+
// BAPI-525 Change B: local-first binding + quiescence gate for the new
|
|
1093
|
+
// dispatched/running admission (policy default OFF ⇒ no behavior change).
|
|
1094
|
+
resolveActivePrBinding,
|
|
1095
|
+
dispatchedBackstopPolicy,
|
|
1096
|
+
resolveActiveTicketQuiescence,
|
|
747
1097
|
// BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
|
|
748
1098
|
// past the head recorded on its latest blocking signal.
|
|
749
1099
|
resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
|
|
@@ -900,8 +1250,12 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
900
1250
|
method: resolveLocalMergeMethod(localCfg.method),
|
|
901
1251
|
approvalRequired: localCfg.approval_required === true,
|
|
902
1252
|
}, { env: process.env }),
|
|
1253
|
+
// BAPI-525 Change A: durable attribution for a worker_id-less gate.met.
|
|
1254
|
+
resolveWorkerIdForGateEvent,
|
|
903
1255
|
});
|
|
904
1256
|
}
|
|
1257
|
+
// Default (backend-route) path, with durable merge attribution wired in.
|
|
1258
|
+
return processGateMetMerge(acc, event, { resolveWorkerIdForGateEvent });
|
|
905
1259
|
}
|
|
906
1260
|
return processMergeFn(acc, event);
|
|
907
1261
|
},
|
|
@@ -1292,8 +1646,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1292
1646
|
// code. With refreshMain:false the worktree was cut from a STALE local
|
|
1293
1647
|
// `main` (never fetched/ff'd after the predecessor merged on origin), so
|
|
1294
1648
|
// dependents built without the predecessor's code — defeating the whole
|
|
1295
|
-
// merge-gated handoff. Refresh (fetch origin
|
|
1649
|
+
// merge-gated handoff. Refresh (fetch origin) before cut.
|
|
1296
1650
|
refreshMain: true,
|
|
1651
|
+
// BAPI-527 (Step 10): unattended conductor dispatch must NOT mutate the
|
|
1652
|
+
// operator's local checkout. `nonMutatingBase: true` resolves origin/<base>
|
|
1653
|
+
// via a fetch-only immutable SHA and cuts new worktrees from it — never a
|
|
1654
|
+
// `git merge --ff-only` / `git branch --force` / branch switch on the host
|
|
1655
|
+
// checkout. Interactive start-tickets keeps the old refreshBaseBranch path.
|
|
1656
|
+
nonMutatingBase: true,
|
|
1297
1657
|
branchOverrides: {},
|
|
1298
1658
|
baseBranch: "main",
|
|
1299
1659
|
conductorEnabled: true,
|
|
@@ -46,6 +46,13 @@ const NON_TERMINAL_STATUSES = new Set([
|
|
|
46
46
|
// reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
|
|
47
47
|
// re-driven by reconcile's dedicated reviewing re-entry branch.
|
|
48
48
|
"reviewing",
|
|
49
|
+
// BAPI-527: asynchronous post-merge parse. Non-terminal so the durable
|
|
50
|
+
// merge.succeeded signal keeps re-folding across ticks until parse-after-merge
|
|
51
|
+
// resolves it to `done` (terminal parse success) or `blocked` (permanent parse
|
|
52
|
+
// failure/timeout). computeReadySet only ever returns `planned`/`ready`, so a
|
|
53
|
+
// parse_pending ticket is never (re)dispatched, and only `done` satisfies a
|
|
54
|
+
// dependency — a merged-but-not-yet-parsed predecessor cannot unblock dependents.
|
|
55
|
+
"parse_pending",
|
|
49
56
|
]);
|
|
50
57
|
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
51
58
|
"gate.met",
|