@bridge_gpt/mcp-server 0.2.16 → 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 +3 -3
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +198 -18
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +76 -25
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +71 -14
- package/build/conductor/epic-runtime.js +839 -67
- package/build/conductor/epic-state.js +524 -63
- package/build/conductor/errors.js +156 -3
- package/build/conductor/event-accessors.js +252 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +19 -72
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +12 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1800 -5166
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +40 -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 +13950 -9669
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- 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 +31 -6
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +47 -99
- package/build/start-tickets-prereqs.js +185 -4
- package/build/start-tickets.js +218 -180
- 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 +10 -6
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3311 -1
- 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 +5 -2
|
@@ -18,12 +18,17 @@
|
|
|
18
18
|
* Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
|
|
19
19
|
*/
|
|
20
20
|
import { spawnSync } from "child_process";
|
|
21
|
-
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup,
|
|
21
|
+
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, updateEpicRunStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, safeDiagnosticMessage, } from "./bridge-api-client.js";
|
|
22
22
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
23
|
+
import { observePrCiOnce } from "./pr-ci-producer.js";
|
|
24
|
+
import { runGhCommand } from "./pr-discovery.js";
|
|
25
|
+
import { isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
23
26
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
|
|
27
|
+
import { getHeadSha, getPrNumber } from "./event-accessors.js";
|
|
24
28
|
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
25
29
|
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
26
30
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
31
|
+
import { normalizeDeclaredTouchedFiles } from "./file-scope-guard.js";
|
|
27
32
|
import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
|
|
28
33
|
import { sendWorkerMessage } from "./store.js";
|
|
29
34
|
import { hashPlan } from "./plan.js";
|
|
@@ -49,6 +54,12 @@ const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
|
|
|
49
54
|
*/
|
|
50
55
|
const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
|
|
51
56
|
const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
|
|
57
|
+
/**
|
|
58
|
+
* BAPI-494: source + producer tags for the durable `merge.conflict` marker the
|
|
59
|
+
* done-gate pass emits when a `ready_for_review` ticket's PR is un-mergeable.
|
|
60
|
+
*/
|
|
61
|
+
const MERGE_CONFLICT_EVENT_SOURCE = "conductor-supervisor";
|
|
62
|
+
const MERGE_CONFLICT_EVENT_PRODUCER = "epic-mergeability";
|
|
52
63
|
function defaultLeaseOwner() {
|
|
53
64
|
return `epic-tick-${process.pid}`;
|
|
54
65
|
}
|
|
@@ -61,6 +72,411 @@ async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
|
|
|
61
72
|
async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
|
|
62
73
|
// no-op: parse-after-merge wait is a sibling ticket's concern
|
|
63
74
|
}
|
|
75
|
+
export function parsePrBindingFromGhJson(stdout) {
|
|
76
|
+
let pr;
|
|
77
|
+
try {
|
|
78
|
+
pr = JSON.parse(stdout);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const num = pr.number;
|
|
84
|
+
const sha = pr.headRefOid;
|
|
85
|
+
const state = typeof pr.state === "string" ? pr.state : "";
|
|
86
|
+
if (typeof num === "number" &&
|
|
87
|
+
Number.isInteger(num) &&
|
|
88
|
+
num >= 1 &&
|
|
89
|
+
typeof sha === "string" &&
|
|
90
|
+
/^[0-9a-f]{7,40}$/i.test(sha) &&
|
|
91
|
+
state.toUpperCase() === "OPEN") {
|
|
92
|
+
// BAPI-494: parse mergeability defensively from the same JSON object — unknown
|
|
93
|
+
// values become null and never reject an otherwise valid open PR binding.
|
|
94
|
+
const mergeability = parseGhPrMergeabilityFields(pr);
|
|
95
|
+
return {
|
|
96
|
+
prNumber: num,
|
|
97
|
+
headSha: sha,
|
|
98
|
+
mergeable: mergeability.mergeable,
|
|
99
|
+
mergeStateStatus: mergeability.mergeStateStatus,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Shared gh-discovery PR-binding resolver: resolve BOTH the PR number and the
|
|
106
|
+
* CURRENT head SHA for a ticket from its `feature/<KEY>` branch via
|
|
107
|
+
* `gh pr view ... --json number,headRefOid,state`. This is the single
|
|
108
|
+
* gh-discovery path used by BOTH the Conductor done-gate pass and the
|
|
109
|
+
* remediation CAS dispatch (BAPI-487 Requirement 1). It intentionally does NOT
|
|
110
|
+
* consult `epic_supervisor_setup.pr_bindings` — nothing auto-populates that map,
|
|
111
|
+
* so the legacy `pr_bindings` seam always returned null and stranded remediation.
|
|
112
|
+
*
|
|
113
|
+
* Fail-closed: returns null for any failed `gh` invocation, empty stdout, or a
|
|
114
|
+
* closed/merged PR / malformed number / non-hex head SHA (all enforced by
|
|
115
|
+
* {@link parsePrBindingFromGhJson}). `options.runGh` is injectable for pure unit
|
|
116
|
+
* tests; `options.cwd` defaults to the Conductor's current working directory.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
|
|
119
|
+
const runGh = options.runGh ?? runGhCommand;
|
|
120
|
+
const ghRes = runGh(
|
|
121
|
+
// BAPI-494: mergeability fields added to the SAME per-ticket binding call — the
|
|
122
|
+
// done-gate reads mergeability inside this existing call, spawning no new gh process.
|
|
123
|
+
["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus"], { cwd: options.cwd ?? process.cwd() });
|
|
124
|
+
if (ghRes.ok && ghRes.stdout.trim()) {
|
|
125
|
+
const parsed = parsePrBindingFromGhJson(ghRes.stdout);
|
|
126
|
+
// Normalize the head SHA to lowercase so downstream head-scoped comparisons
|
|
127
|
+
// (blocked-head vs. current-head, gate.met dedupe) are case-stable.
|
|
128
|
+
return parsed
|
|
129
|
+
? { ...parsed, headSha: parsed.headSha.toLowerCase() }
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
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
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
|
|
330
|
+
* live PR head has advanced past the head that blocked it — ask the conductor to
|
|
331
|
+
* evaluate the composite done-gate itself via {@link observePrCiOnce}, the
|
|
332
|
+
* correctness backstop for FINDING 6 where a worker never self-emits `gate.met`.
|
|
333
|
+
* `observePrCiOnce` binds to the CURRENT head SHA, fails closed on any
|
|
334
|
+
* unavailable/ambiguous/stale-head signal, and idempotently emits `gate.met`
|
|
335
|
+
* (deduped by ticket+PR+head_sha) so the fold + merge-enqueue happens exactly once
|
|
336
|
+
* regardless of whether the worker also emitted it.
|
|
337
|
+
*
|
|
338
|
+
* A `blocked` ticket is re-evaluated ONLY when its PR binding resolves AND either
|
|
339
|
+
* the recorded blocked head is unknown (fail-closed recovery) or the current head
|
|
340
|
+
* differs from it — a ticket still blocked on its current head is left blocked so
|
|
341
|
+
* a same-head failure is never bypassed.
|
|
342
|
+
*
|
|
343
|
+
* Fully fail-closed and non-blocking: a per-ticket observation error is caught and
|
|
344
|
+
* logged so it can never crash or stall the reconcile loop; tickets that are
|
|
345
|
+
* neither `ready_for_review` nor advanced-head `blocked`, and tickets without a
|
|
346
|
+
* PR binding, are skipped.
|
|
347
|
+
*/
|
|
348
|
+
export async function runConductorDoneGatePass(ticketStatuses, deps) {
|
|
349
|
+
const backstopEnabled = deps.dispatchedBackstopPolicy?.enabled === true;
|
|
350
|
+
for (const [ticketKey, status] of ticketStatuses) {
|
|
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))
|
|
357
|
+
continue;
|
|
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);
|
|
364
|
+
if (prBinding === null) {
|
|
365
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
|
|
366
|
+
continue;
|
|
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
|
+
}
|
|
382
|
+
// BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
|
|
383
|
+
if (status === "blocked") {
|
|
384
|
+
const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
|
|
385
|
+
if (blockedHead === null) {
|
|
386
|
+
deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked with no recorded head; ` +
|
|
387
|
+
`polling current head ${prBinding.headSha} fail-closed as recovery`);
|
|
388
|
+
}
|
|
389
|
+
else if (blockedHead.toLowerCase() === prBinding.headSha.toLowerCase()) {
|
|
390
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (still blocked on current head ${prBinding.headSha})`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked head ${blockedHead} ` +
|
|
395
|
+
`superseded by current head ${prBinding.headSha}; re-evaluating`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
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.
|
|
405
|
+
const runId = deps.resolveRunId?.(ticketKey) ?? null;
|
|
406
|
+
const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
|
|
407
|
+
// BAPI-494: a `ready_for_review` PR that is already un-mergeable
|
|
408
|
+
// (CONFLICTING/DIRTY) can never be re-reviewed (claude-review won't verdict an
|
|
409
|
+
// unmergeable branch) or merged — so it would sit at ready_for_review forever.
|
|
410
|
+
// Route it into the head-scoped `merge.conflict` blocking path (folded next
|
|
411
|
+
// tick, remediated as a resume-mode redispatch) BEFORE re-polling CI, and skip
|
|
412
|
+
// CI observation for this ticket. Only `ready_for_review` conflicts are emitted
|
|
413
|
+
// here; a `blocked` ticket already has routing + stale-head re-evaluation.
|
|
414
|
+
if (status === "ready_for_review" && isPrMergeConflict(prBinding)) {
|
|
415
|
+
try {
|
|
416
|
+
await deps.emitConflictSignal?.({
|
|
417
|
+
ticketKey,
|
|
418
|
+
repoName: deps.access.repoName,
|
|
419
|
+
prNumber: prBinding.prNumber,
|
|
420
|
+
headSha: prBinding.headSha,
|
|
421
|
+
mergeable: prBinding.mergeable,
|
|
422
|
+
mergeStateStatus: prBinding.mergeStateStatus,
|
|
423
|
+
runId,
|
|
424
|
+
workerId,
|
|
425
|
+
});
|
|
426
|
+
deps.log(`[epic-tick] done-gate conflict for ${ticketKey}: PR #${prBinding.prNumber} ` +
|
|
427
|
+
`not mergeable at head ${prBinding.headSha}; emitted merge.conflict`);
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "conflict emit error";
|
|
431
|
+
deps.errorLog(`[epic-tick] done-gate conflict-signal failed (${safeMsg}) for ${ticketKey}; continuing`);
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const perTicketEnv = {
|
|
436
|
+
...deps.env,
|
|
437
|
+
...(runId ? { BAPI_CONDUCTOR_RUN_ID: runId } : {}),
|
|
438
|
+
...(workerId ? { BAPI_CONDUCTOR_WORKER_ID: workerId } : {}),
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
const observeResult = await deps.observePrCi({
|
|
442
|
+
repoName: deps.access.repoName,
|
|
443
|
+
prNumber: prBinding.prNumber,
|
|
444
|
+
headSha: prBinding.headSha,
|
|
445
|
+
}, {
|
|
446
|
+
env: perTicketEnv,
|
|
447
|
+
resolveAccess: async () => ({ ok: true, access: deps.access }),
|
|
448
|
+
});
|
|
449
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: ${observeResult.reason}`);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "observe error";
|
|
453
|
+
deps.errorLog(`[epic-tick] done-gate poll failed (${safeMsg}) for ${ticketKey}; continuing`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// N-3 run self-completion (BAPI-507)
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
/**
|
|
461
|
+
* BAPI-507 (N-3): should the epic run self-complete this tick? True ONLY when the
|
|
462
|
+
* approved plan has at least one ticket AND every plan ticket's effective status
|
|
463
|
+
* is exactly `"done"` in `observed.ticket_statuses`. Any node that is
|
|
464
|
+
* `abandoned` / `blocked` / `ready_for_review` / `running` / `reviewing` /
|
|
465
|
+
* `ready` / `dispatched` / `planned`, missing from the status map, or any other
|
|
466
|
+
* non-`done` value makes this return `false`. Conservative done-only policy: a
|
|
467
|
+
* plan ending in a `done` + `abandoned` mix does NOT auto-complete — an operator
|
|
468
|
+
* decides how to close a run with abandoned nodes. Pure: no I/O, no clock.
|
|
469
|
+
*/
|
|
470
|
+
function shouldSelfCompleteEpicRun(plan, observed) {
|
|
471
|
+
if (plan.tickets.length === 0)
|
|
472
|
+
return false;
|
|
473
|
+
for (const ticket of plan.tickets) {
|
|
474
|
+
if (observed.ticket_statuses.get(ticket.ticket_key) !== "done") {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
64
480
|
// ---------------------------------------------------------------------------
|
|
65
481
|
// runEpicTick
|
|
66
482
|
// ---------------------------------------------------------------------------
|
|
@@ -91,6 +507,28 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
91
507
|
const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
|
|
92
508
|
const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
|
|
93
509
|
const releaseLease = deps.releaseLease;
|
|
510
|
+
const env = deps.env ?? process.env;
|
|
511
|
+
const observePrCiSeamFn = deps.observePrCiSeam ?? observePrCiOnce;
|
|
512
|
+
// BAPI-507 (N-3): default self-completion seam CASes the run active → done via
|
|
513
|
+
// the existing PATCH /runs/{id} path, addressing the run by its concrete UUID.
|
|
514
|
+
const completeEpicRunFn = deps.completeEpicRun ??
|
|
515
|
+
(async (acc, epicRunId) => {
|
|
516
|
+
await updateEpicRunStatus(acc, {
|
|
517
|
+
epicKey: epicRunId,
|
|
518
|
+
status: "done",
|
|
519
|
+
expectedStatus: "active",
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
// BAPI-494: a single durable event emitter reused by BOTH the parse-after-merge
|
|
523
|
+
// marker block and the done-gate `merge.conflict` wiring, so there is one
|
|
524
|
+
// injectable ledger path (not a second hardcoded emitter).
|
|
525
|
+
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
526
|
+
// FINDING 1 (BAPI-463): a dry-run tick must exercise the full dispatch-resolution
|
|
527
|
+
// path (ready-set → resolved command + model routing) yet persist NO authoritative
|
|
528
|
+
// dispatch state. Without this guard, `correlateRunId` writes a `run_spawned` row
|
|
529
|
+
// under the REAL dispatch_key, and the next real tick's `claimDispatchKey` returns
|
|
530
|
+
// `already-spawned`, silently skipping the real worker spawn.
|
|
531
|
+
const isDryRun = env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
94
532
|
const startMs = nowFn();
|
|
95
533
|
// ---------------------------------------------------------------------------
|
|
96
534
|
// Step 0: Resolve Bridge API access (offline / fail-closed guard)
|
|
@@ -222,10 +660,8 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
222
660
|
const isParseWaitEnabled = pamConfig?.enabled === true;
|
|
223
661
|
if (isParseWaitEnabled) {
|
|
224
662
|
const maxWaitMs = typeof pamConfig?.max_wait_ms === "number" ? pamConfig.max_wait_ms : 10 * 60 * 1000;
|
|
225
|
-
const settleMs = 5000;
|
|
226
663
|
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
227
664
|
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
228
|
-
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
229
665
|
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
230
666
|
const signal = observed.unfolded_terminal_signals[i];
|
|
231
667
|
if (signal.signal_type !== "merge.succeeded")
|
|
@@ -260,26 +696,52 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
260
696
|
e.run_id === mergeRunId &&
|
|
261
697
|
new Date(e.time).getTime() >= mergeTimeMs);
|
|
262
698
|
const elapsedMs = nowFn() - mergeTimeMs;
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// the
|
|
267
|
-
|
|
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) => {
|
|
268
723
|
if (currentPgStatus === "blocked") {
|
|
269
724
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
270
725
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
271
726
|
i -= 1;
|
|
272
|
-
|
|
727
|
+
return;
|
|
273
728
|
}
|
|
274
|
-
await escalateOnce(epic_key,
|
|
729
|
+
await escalateOnce(epic_key, reason);
|
|
275
730
|
signal.next_status = "blocked";
|
|
276
731
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
277
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}`);
|
|
278
738
|
continue;
|
|
279
739
|
}
|
|
280
740
|
if (!parseTriggeredEvent) {
|
|
281
|
-
// No durable trigger for this merge yet — fire the parse
|
|
282
|
-
// 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.
|
|
283
745
|
try {
|
|
284
746
|
await triggerParseFn(access);
|
|
285
747
|
emitConductorEventFn({
|
|
@@ -304,16 +766,20 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
304
766
|
run_id: mergeRunId ?? undefined,
|
|
305
767
|
commit_sha: mergeHeadSha,
|
|
306
768
|
});
|
|
307
|
-
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();
|
|
308
771
|
}
|
|
309
772
|
catch (err) {
|
|
310
773
|
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
311
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();
|
|
312
778
|
}
|
|
313
|
-
revertSignal();
|
|
314
779
|
continue;
|
|
315
780
|
}
|
|
316
|
-
// 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).
|
|
317
783
|
let parseStatusResult;
|
|
318
784
|
try {
|
|
319
785
|
parseStatusResult = await fetchParseStatusFn(access);
|
|
@@ -321,30 +787,84 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
321
787
|
catch (err) {
|
|
322
788
|
const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
|
|
323
789
|
errorLog(`[epic-tick] parse-status check failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
324
|
-
|
|
790
|
+
// Transient status-fetch failure: hold in parse_pending for the next tick.
|
|
791
|
+
holdOrEnterParsePending();
|
|
325
792
|
continue;
|
|
326
793
|
}
|
|
327
|
-
if (parseStatusResult.status === "
|
|
328
|
-
//
|
|
329
|
-
|
|
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}`);
|
|
330
829
|
continue;
|
|
331
830
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
345
861
|
continue;
|
|
346
862
|
}
|
|
347
|
-
//
|
|
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();
|
|
348
868
|
}
|
|
349
869
|
}
|
|
350
870
|
// Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
|
|
@@ -393,6 +913,12 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
393
913
|
// liveness window) and setup (pr_bindings) once. Fail-open: if the config
|
|
394
914
|
// read fails, remediationConfig stays undefined and reconcile skips the
|
|
395
915
|
// remediation pass entirely (dispatch/merge steps unaffected).
|
|
916
|
+
//
|
|
917
|
+
// BAPI-487: `prBindings` is retained ONLY for the legacy teardown seam
|
|
918
|
+
// (which needs just the PR number). Both the done-gate observation and the
|
|
919
|
+
// remediation CAS resolve their PR binding via gh-discovery
|
|
920
|
+
// (resolveTicketPrBindingFromGh), never from this map — nothing
|
|
921
|
+
// auto-populates `pr_bindings`, so relying on it stranded remediation.
|
|
396
922
|
let remediationConfig;
|
|
397
923
|
let livenessWindowSeconds = 120;
|
|
398
924
|
let prBindings = {};
|
|
@@ -461,6 +987,14 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
461
987
|
for (const [tk, info] of reviewLatestDispatchByTicket) {
|
|
462
988
|
reviewTicketRunIdMap.set(tk, info.runId);
|
|
463
989
|
}
|
|
990
|
+
// Legacy PR-number-only resolver from `pr_bindings`. BAPI-487: this is now
|
|
991
|
+
// consumed ONLY by the teardownSeam, which needs just the number and is
|
|
992
|
+
// outside this ticket's remediation critical path. Both the done-gate
|
|
993
|
+
// observation and the remediation CAS resolve their PR binding via
|
|
994
|
+
// gh-discovery (resolveTicketPrBindingFromGh / resolvePrBinding) instead,
|
|
995
|
+
// because nothing auto-populates `pr_bindings` (confirmed NULL for repo
|
|
996
|
+
// `bapi`), so this legacy path always returns null. Teardown is left on it
|
|
997
|
+
// deliberately rather than silently migrated.
|
|
464
998
|
const resolvePrNumber = (ticketKey) => {
|
|
465
999
|
const raw = prBindings[ticketKey];
|
|
466
1000
|
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
@@ -473,6 +1007,138 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
473
1007
|
}
|
|
474
1008
|
return null;
|
|
475
1009
|
};
|
|
1010
|
+
// gh-discovery PR binding used by BOTH the done-gate pass and the
|
|
1011
|
+
// remediation CAS (BAPI-487 Requirement 1). Delegates to the shared
|
|
1012
|
+
// `resolveTicketPrBindingFromGh` helper so the two seams cannot diverge; it
|
|
1013
|
+
// resolves the number AND the current head SHA from `feature/<KEY>` and
|
|
1014
|
+
// fail-closes unless the PR is OPEN with a well-formed number + head SHA.
|
|
1015
|
+
const resolvePrBinding = (ticketKey) => resolveTicketPrBindingFromGh(ticketKey, { cwd: process.cwd() });
|
|
1016
|
+
// ---------------------------------------------------------------------
|
|
1017
|
+
// Step 4.5: Conductor-driven done-gate for ready_for_review tickets
|
|
1018
|
+
// (FINDING 6, BAPI-463) — the correctness backstop for the hands-off run.
|
|
1019
|
+
//
|
|
1020
|
+
// Workers do not reliably self-emit `gate.met` (a ticket can reach
|
|
1021
|
+
// ready_for_review via `run.stopped` alone), so the merge enqueue — gated on
|
|
1022
|
+
// `gate.met` only — would stall forever awaiting a worker signal. Here the
|
|
1023
|
+
// CONDUCTOR evaluates the composite done-gate itself via the existing
|
|
1024
|
+
// `observePrCiOnce`, which binds to the resolved CURRENT head SHA, evaluates
|
|
1025
|
+
// the pure fail-closed done-gate (required-CI green + current-head review
|
|
1026
|
+
// verdict), and idempotently emits `gate.met` (deduped by ticket+PR+head_sha)
|
|
1027
|
+
// so this tick or the next one folds it and enqueues the merge exactly once.
|
|
1028
|
+
// Worker-emitted `gate.met` stays a valid fast-path; this pass never removes it.
|
|
1029
|
+
// The pass is best-effort and fully fail-closed: an observation error is
|
|
1030
|
+
// caught per-ticket so it can never crash or block the reconcile loop, and
|
|
1031
|
+
// `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
|
|
1032
|
+
// run_id → worker_id from the ledger (first worker_id seen per run) so the
|
|
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.
|
|
1038
|
+
const runIdToWorkerId = new Map();
|
|
1039
|
+
for (const ev of localEvents) {
|
|
1040
|
+
if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
|
|
1041
|
+
runIdToWorkerId.set(ev.run_id, ev.worker_id);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const resolveTicketRunId = (tk) => ticketRunIdMap.get(tk) ?? latestDispatchByTicket.get(tk)?.runId ?? null;
|
|
1045
|
+
const resolveDoneGateWorkerId = (tk) => {
|
|
1046
|
+
const rid = resolveTicketRunId(tk);
|
|
1047
|
+
if (rid && runIdToWorkerId.has(rid))
|
|
1048
|
+
return runIdToWorkerId.get(rid);
|
|
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;
|
|
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
|
+
});
|
|
1089
|
+
await runConductorDoneGatePass(observed.ticket_statuses, {
|
|
1090
|
+
observePrCi: observePrCiSeamFn,
|
|
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,
|
|
1097
|
+
// BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
|
|
1098
|
+
// past the head recorded on its latest blocking signal.
|
|
1099
|
+
resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
|
|
1100
|
+
resolveRunId: resolveTicketRunId,
|
|
1101
|
+
resolveWorkerId: resolveDoneGateWorkerId,
|
|
1102
|
+
// BAPI-494: convert a detected conflict into a durable, head-scoped
|
|
1103
|
+
// `merge.conflict` ledger event stamped with the ticket's dispatch run/worker
|
|
1104
|
+
// so the fold correlates it and the remediation pass redispatches. Emitted via
|
|
1105
|
+
// the shared injectable emitter, idempotent per conflict head. Folded next tick
|
|
1106
|
+
// (this pass runs after rebuildObservedState), matching the gate.met latency.
|
|
1107
|
+
emitConflictSignal: (input) => {
|
|
1108
|
+
emitConductorEventFn({
|
|
1109
|
+
source: MERGE_CONFLICT_EVENT_SOURCE,
|
|
1110
|
+
type: "merge.conflict",
|
|
1111
|
+
subject: input.ticketKey,
|
|
1112
|
+
run_id: input.runId,
|
|
1113
|
+
worker_id: input.workerId,
|
|
1114
|
+
producer: MERGE_CONFLICT_EVENT_PRODUCER,
|
|
1115
|
+
observed_via: "supervisor",
|
|
1116
|
+
time: new Date(nowFn()).toISOString(),
|
|
1117
|
+
data: {
|
|
1118
|
+
summary: `PR #${input.prNumber} for ${input.ticketKey} is not mergeable`,
|
|
1119
|
+
status: "blocked",
|
|
1120
|
+
reason: "merge.conflict",
|
|
1121
|
+
details: {
|
|
1122
|
+
epic_key,
|
|
1123
|
+
ticket_key: input.ticketKey,
|
|
1124
|
+
repo: input.repoName,
|
|
1125
|
+
pr_number: input.prNumber,
|
|
1126
|
+
head_sha: input.headSha,
|
|
1127
|
+
mergeable: input.mergeable,
|
|
1128
|
+
mergeStateStatus: input.mergeStateStatus,
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
}, {
|
|
1132
|
+
event_type: "merge.conflict",
|
|
1133
|
+
run_id: input.runId ?? undefined,
|
|
1134
|
+
commit_sha: input.headSha,
|
|
1135
|
+
});
|
|
1136
|
+
},
|
|
1137
|
+
access,
|
|
1138
|
+
env,
|
|
1139
|
+
log,
|
|
1140
|
+
errorLog,
|
|
1141
|
+
});
|
|
476
1142
|
const maxSeqForRun = (runId) => {
|
|
477
1143
|
let maxSeq = 0;
|
|
478
1144
|
for (const ev of localEvents) {
|
|
@@ -489,25 +1155,57 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
489
1155
|
nextStatus,
|
|
490
1156
|
planVersion,
|
|
491
1157
|
}),
|
|
492
|
-
seedTicketStatus: async (
|
|
1158
|
+
seedTicketStatus: async (_ek, tk, planVersion) => {
|
|
1159
|
+
// A6/BAPI-507: address the run by its concrete epic_run_id — the
|
|
1160
|
+
// endpoint path is /runs/{epic_run_id}/tickets. Passing the UUID (not
|
|
1161
|
+
// the Jira epic key `_ek`) skips the epic-key→active-run resolution and
|
|
1162
|
+
// its multi-active-run CONFLICT edge. The backend read-back makes a
|
|
1163
|
+
// re-seed of an existing row a clean 2xx no-op.
|
|
493
1164
|
await createEpicTicketStatus(access, {
|
|
494
|
-
epicKey:
|
|
1165
|
+
epicKey: epicRunState.epic_run.epic_run_id,
|
|
495
1166
|
ticketKey: tk,
|
|
496
1167
|
status: "planned",
|
|
497
1168
|
planVersion,
|
|
498
1169
|
});
|
|
499
1170
|
},
|
|
500
|
-
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) =>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1171
|
+
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => {
|
|
1172
|
+
// FINDING 1 (BAPI-463): in dry-run mode, DO NOT persist a real dispatch
|
|
1173
|
+
// claim. Return a synthetic "claimed" result (run_id null) so the reconcile
|
|
1174
|
+
// pass proceeds to resolve the command + model routing (still exercised) but
|
|
1175
|
+
// no authoritative `run_spawned` row is written under the real dispatch_key.
|
|
1176
|
+
if (isDryRun) {
|
|
1177
|
+
const suffix = `${role === "review" ? ":review" : ""}${attempt > 0 ? `:r${attempt}` : ""}`;
|
|
1178
|
+
const nowIso = new Date(nowFn()).toISOString();
|
|
1179
|
+
log(`[DRY RUN] claiming dispatch key skipped for ${tk} (epic=${ek}, role=${role ?? "implementation"}, attempt=${attempt}); no run_spawned row persisted`);
|
|
1180
|
+
return {
|
|
1181
|
+
ok: true,
|
|
1182
|
+
kind: "claimed",
|
|
1183
|
+
dispatch: {
|
|
1184
|
+
dispatch_key: `dry-run:${ek}:${tk}:v${planVersion}${suffix}`,
|
|
1185
|
+
epic_run_id: ek,
|
|
1186
|
+
ticket_key: tk,
|
|
1187
|
+
plan_version: planVersion,
|
|
1188
|
+
status: "pending",
|
|
1189
|
+
run_id: null,
|
|
1190
|
+
lease_owner: lease_owner,
|
|
1191
|
+
lease_expires_at: null,
|
|
1192
|
+
created_at: nowIso,
|
|
1193
|
+
updated_at: nowIso,
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
return recordEpicDispatch(access, {
|
|
1198
|
+
epicKey: ek,
|
|
1199
|
+
ticketKey: tk,
|
|
1200
|
+
planVersion,
|
|
1201
|
+
leaseOwner: lease_owner,
|
|
1202
|
+
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
1203
|
+
attempt,
|
|
1204
|
+
// BAPI-445: a review-role claim appends ":review" to the dispatch key
|
|
1205
|
+
// so the run-id maps above can separate review runs from impl runs.
|
|
1206
|
+
reviewRole: role === "review",
|
|
1207
|
+
});
|
|
1208
|
+
},
|
|
511
1209
|
// BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
|
|
512
1210
|
// factory provides it (gate stays off otherwise); the liveness + attempt
|
|
513
1211
|
// accessors read the review-scoped maps built above.
|
|
@@ -522,6 +1220,13 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
522
1220
|
},
|
|
523
1221
|
countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
|
|
524
1222
|
correlateRunId: async (dispatchKey, runId) => {
|
|
1223
|
+
// FINDING 1 (BAPI-463): dry-run must not transition the dispatch row to
|
|
1224
|
+
// `run_spawned`. Skipping this keeps the real dispatch_key un-claimed so a
|
|
1225
|
+
// subsequent real tick spawns the worker instead of hitting `already-spawned`.
|
|
1226
|
+
if (isDryRun) {
|
|
1227
|
+
log(`[DRY RUN] correlate run_id skipped for dispatch_key=${dispatchKey} (run_id=${runId}); no run_spawned transition persisted`);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
525
1230
|
await transitionEpicDispatch(access, {
|
|
526
1231
|
dispatchKey,
|
|
527
1232
|
nextStatus: "run_spawned",
|
|
@@ -545,8 +1250,12 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
545
1250
|
method: resolveLocalMergeMethod(localCfg.method),
|
|
546
1251
|
approvalRequired: localCfg.approval_required === true,
|
|
547
1252
|
}, { env: process.env }),
|
|
1253
|
+
// BAPI-525 Change A: durable attribution for a worker_id-less gate.met.
|
|
1254
|
+
resolveWorkerIdForGateEvent,
|
|
548
1255
|
});
|
|
549
1256
|
}
|
|
1257
|
+
// Default (backend-route) path, with durable merge attribution wired in.
|
|
1258
|
+
return processGateMetMerge(acc, event, { resolveWorkerIdForGateEvent });
|
|
550
1259
|
}
|
|
551
1260
|
return processMergeFn(acc, event);
|
|
552
1261
|
},
|
|
@@ -615,19 +1324,30 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
615
1324
|
return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
|
|
616
1325
|
},
|
|
617
1326
|
remediateCas: async (ek, tk, attemptKind, reason) => {
|
|
618
|
-
|
|
619
|
-
|
|
1327
|
+
// BAPI-487 Requirement 1: resolve the PR number + head SHA via the same
|
|
1328
|
+
// gh-discovery the done-gate pass uses, NOT the never-populated
|
|
1329
|
+
// `pr_bindings` map (which stranded remediation on `remediate: no PR
|
|
1330
|
+
// binding`). resolvePrBinding returns both the number and the current
|
|
1331
|
+
// head SHA, so the separate legacy review-status head-SHA fetch is gone.
|
|
1332
|
+
const prBinding = resolvePrBinding(tk);
|
|
1333
|
+
if (prBinding === null) {
|
|
620
1334
|
throw new Error(`remediate: no PR binding for ${tk}`);
|
|
621
1335
|
}
|
|
622
|
-
const
|
|
623
|
-
const headSha =
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1336
|
+
const prNumber = prBinding.prNumber;
|
|
1337
|
+
const headSha = prBinding.headSha;
|
|
1338
|
+
// BAPI-500: remediation runs AFTER the fold step in the SAME reconcile
|
|
1339
|
+
// pass. When this ticket folded to `blocked` this tick, the block CAS
|
|
1340
|
+
// already bumped its row_version to snapshot+1, so prefer the confirmed
|
|
1341
|
+
// same-pass post-fold version; fall back to the tick-start snapshot when
|
|
1342
|
+
// the ticket was not folded this tick, then to 0. Sending the stale
|
|
1343
|
+
// tick-start snapshot is exactly the deterministic 400 loop this fixes.
|
|
1344
|
+
const rowVersion = observed.ticket_post_fold_row_versions?.get(tk) ??
|
|
1345
|
+
observed.ticket_row_versions.get(tk) ??
|
|
1346
|
+
0;
|
|
628
1347
|
// Deterministic block-state idempotency key: stable for a given durable
|
|
629
1348
|
// row_version so a same-tick retry replays (409, swallowed); advances
|
|
630
|
-
// with the next attempt.
|
|
1349
|
+
// with the next attempt. Derived from the SAME local `rowVersion` sent
|
|
1350
|
+
// as `expected_row_version` so the key and the CAS token stay in lockstep.
|
|
631
1351
|
const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
|
|
632
1352
|
const result = await remediateEpicTicket(access, {
|
|
633
1353
|
pr_number: prNumber,
|
|
@@ -695,6 +1415,24 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
695
1415
|
for (const w of reconcileResult.warnings) {
|
|
696
1416
|
errorLog(`[epic-tick] warning: ${w}`);
|
|
697
1417
|
}
|
|
1418
|
+
// Step 5.5 (N-3 / BAPI-507): self-complete the run once every approved-plan
|
|
1419
|
+
// ticket is durably `done`. Deferred while any terminal signal folded THIS
|
|
1420
|
+
// tick is still unpersisted (`unfolded_terminal_signals`) so the run closes
|
|
1421
|
+
// on a SUBSEQUENT tick — after Postgres durably reflects all-done — rather
|
|
1422
|
+
// than from a same-tick local projection. Fail-open: a stale CAS (a
|
|
1423
|
+
// concurrent terminal transition already closed the run, or the top-of-tick
|
|
1424
|
+
// terminal guard will) is bounded operational noise, never a tick failure.
|
|
1425
|
+
if (shouldSelfCompleteEpicRun(plan, observed) &&
|
|
1426
|
+
observed.unfolded_terminal_signals.length === 0) {
|
|
1427
|
+
try {
|
|
1428
|
+
await completeEpicRunFn(access, epicRunState.epic_run.epic_run_id);
|
|
1429
|
+
log(`[epic-tick] epic=${epic_key} self-completed: all plan tickets done`);
|
|
1430
|
+
}
|
|
1431
|
+
catch (err) {
|
|
1432
|
+
errorLog(`[epic-tick] self-completion CAS failed for epic=${epic_key}: ` +
|
|
1433
|
+
`${safeDiagnosticMessage(err, "self-complete error")}`);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
698
1436
|
}
|
|
699
1437
|
else {
|
|
700
1438
|
log(`[epic-tick] no plan available for epic=${epic_key}; skipping dispatch and merge steps`);
|
|
@@ -780,6 +1518,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
780
1518
|
// Shared closure state populated by fetchPlan and consumed by dispatchSeam.
|
|
781
1519
|
let cachedPlanVersion = 0;
|
|
782
1520
|
const automationMap = new Map();
|
|
1521
|
+
// BAPI-507 (N-2): per-ticket normalized declared touched-file set, sourced from
|
|
1522
|
+
// the plan DAG node's `touched_files`. Consumed by dispatchSeam to inject the
|
|
1523
|
+
// declared file scope into the implementation worker's environment.
|
|
1524
|
+
const touchedFilesMap = new Map();
|
|
783
1525
|
const fetchPlan = async (ek, acc) => {
|
|
784
1526
|
let response;
|
|
785
1527
|
try {
|
|
@@ -797,18 +1539,32 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
797
1539
|
return null;
|
|
798
1540
|
const dag = response.plan_blob;
|
|
799
1541
|
cachedPlanVersion = response.plan_version;
|
|
800
|
-
// Populate the automation
|
|
1542
|
+
// Populate the automation + declared-touched-files maps for the dispatch seam.
|
|
801
1543
|
for (const node of dag.nodes) {
|
|
1544
|
+
const nodeKey = node.ticket_key.trim();
|
|
802
1545
|
const kind = node.automations?.[0]?.kind;
|
|
803
1546
|
if (kind) {
|
|
804
|
-
automationMap.set(
|
|
1547
|
+
automationMap.set(nodeKey, kind);
|
|
1548
|
+
}
|
|
1549
|
+
// BAPI-507 (N-2): normalize defensively (trim, drop blanks, dedupe, sort;
|
|
1550
|
+
// no filesystem access). Missing/non-array touched_files → unspecified, so
|
|
1551
|
+
// the ticket simply gets no declared scope rather than failing dispatch.
|
|
1552
|
+
const declared = normalizeDeclaredTouchedFiles(node.touched_files);
|
|
1553
|
+
if (declared.length > 0) {
|
|
1554
|
+
touchedFilesMap.set(nodeKey, declared);
|
|
805
1555
|
}
|
|
806
1556
|
}
|
|
807
|
-
// Map DAG nodes to EpicTicketNode
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1557
|
+
// Map DAG nodes to EpicTicketNode, preserving each node's normalized declared
|
|
1558
|
+
// touched-file set (drop automations/status/edges).
|
|
1559
|
+
const tickets = dag.nodes.map((n) => {
|
|
1560
|
+
const nodeKey = n.ticket_key.trim();
|
|
1561
|
+
const declared = touchedFilesMap.get(nodeKey);
|
|
1562
|
+
return {
|
|
1563
|
+
ticket_key: nodeKey,
|
|
1564
|
+
depends_on: (n.depends_on ?? []).map((k) => k.trim()),
|
|
1565
|
+
...(declared && declared.length > 0 ? { touched_files: declared } : {}),
|
|
1566
|
+
};
|
|
1567
|
+
});
|
|
812
1568
|
// Recompute the hash locally — do NOT trust the server-returned plan_hash.
|
|
813
1569
|
// This makes the integrity gate fail-closed: a tampered or drifted blob
|
|
814
1570
|
// will hash differently than approved_plan_hash and halt the tick.
|
|
@@ -836,11 +1592,18 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
836
1592
|
// path (ready-set → claim key → dispatch → correlate run_id) still exercises;
|
|
837
1593
|
// dry-run rows carry no runId, so a synthetic one is substituted for correlation.
|
|
838
1594
|
const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
1595
|
+
// BAPI-507 (N-2): thread the ticket's declared file scope into the
|
|
1596
|
+
// IMPLEMENTATION dispatch identity only. A `review-tickets` node does not open
|
|
1597
|
+
// an implementation PR, so it gets no file-scope env (Step 8.5).
|
|
1598
|
+
const declaredTouchedFiles = kind === "review-tickets" ? undefined : touchedFilesMap.get(tk);
|
|
839
1599
|
const identity = {
|
|
840
1600
|
epic_key: ek,
|
|
841
1601
|
epic_run_id: ek,
|
|
842
1602
|
plan_version: cachedPlanVersion,
|
|
843
1603
|
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
|
|
1604
|
+
...(declaredTouchedFiles && declaredTouchedFiles.length > 0
|
|
1605
|
+
? { declared_touched_files: declaredTouchedFiles }
|
|
1606
|
+
: {}),
|
|
844
1607
|
};
|
|
845
1608
|
const deps = createDefaultStartTicketsDeps();
|
|
846
1609
|
let runId;
|
|
@@ -853,6 +1616,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
853
1616
|
maxParallel: 1,
|
|
854
1617
|
auto: true,
|
|
855
1618
|
reviewOverrides: {},
|
|
1619
|
+
// BAPI-474: the Conductor epic-dispatch path stays git-fetch-free — it
|
|
1620
|
+
// already dispatches into the correct worktree/branch context, so the
|
|
1621
|
+
// fresh-base materialization (an interactive-CLI concern) is unneeded here.
|
|
1622
|
+
noRefreshBase: true,
|
|
856
1623
|
});
|
|
857
1624
|
if (!result.ok) {
|
|
858
1625
|
throw new Error(`review-tickets dispatch failed: ${result.error}`);
|
|
@@ -879,8 +1646,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
879
1646
|
// code. With refreshMain:false the worktree was cut from a STALE local
|
|
880
1647
|
// `main` (never fetched/ff'd after the predecessor merged on origin), so
|
|
881
1648
|
// dependents built without the predecessor's code — defeating the whole
|
|
882
|
-
// merge-gated handoff. Refresh (fetch origin
|
|
1649
|
+
// merge-gated handoff. Refresh (fetch origin) before cut.
|
|
883
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,
|
|
884
1657
|
branchOverrides: {},
|
|
885
1658
|
baseBranch: "main",
|
|
886
1659
|
conductorEnabled: true,
|
|
@@ -941,6 +1714,9 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
941
1714
|
// Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
|
|
942
1715
|
rounds: 2,
|
|
943
1716
|
reviewOverrides: {},
|
|
1717
|
+
// BAPI-474: see the sibling dispatchSeam comment — Conductor dispatch stays
|
|
1718
|
+
// git-fetch-free.
|
|
1719
|
+
noRefreshBase: true,
|
|
944
1720
|
});
|
|
945
1721
|
if (!result.ok) {
|
|
946
1722
|
throw new Error(`spec re-review dispatch failed: ${result.error}`);
|
|
@@ -1009,7 +1785,6 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1009
1785
|
state: null,
|
|
1010
1786
|
liveness: null,
|
|
1011
1787
|
elapsed_ms: 0,
|
|
1012
|
-
ambiguous: false,
|
|
1013
1788
|
context: {},
|
|
1014
1789
|
};
|
|
1015
1790
|
// Attempt to extract a ticket key from structured reason strings like
|
|
@@ -1022,10 +1797,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1022
1797
|
const assessment = {
|
|
1023
1798
|
classification: "stuck",
|
|
1024
1799
|
confidence: 1,
|
|
1025
|
-
should_escalate: true,
|
|
1026
1800
|
reason,
|
|
1027
|
-
draft_escalation_text: null,
|
|
1028
|
-
source: "degraded",
|
|
1029
1801
|
};
|
|
1030
1802
|
const idempotencyKey = makeSupervisorIdempotencyKey({
|
|
1031
1803
|
run_id: ek,
|