@bridge_gpt/mcp-server 0.2.10 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -4
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +2 -1
- package/build/conductor/cli.js +16 -16
- package/build/conductor/doctor.js +79 -2
- package/build/conductor/epic-reconcile.js +213 -16
- package/build/conductor/epic-runtime.js +215 -60
- package/build/conductor/epic-state.js +105 -16
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +10 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/local-merge.js +212 -0
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +18 -8
- package/build/conductor/pr-review-producer.js +2 -2
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +100 -25
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +1 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +9 -0
- package/build/conductor/tools.js +5 -5
- package/build/conductor-bin.js +12689 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +203 -0
- package/build/index.js +25084 -3632
- package/build/init.js +57 -0
- package/build/install-bridge.js +80 -0
- package/build/mcp-profile.js +33 -30
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +151 -0
- package/build/sfcc/config.js +39 -0
- package/build/sfcc/credentials.js +136 -0
- package/build/sfcc/ocapi-shape.js +77 -0
- package/build/sfcc/output.js +39 -0
- package/build/sfcc/permissions.js +136 -0
- package/build/sfcc/reads-custom-object-def.js +119 -0
- package/build/sfcc/reads-site-preference.js +158 -0
- package/build/sfcc/reads-system-object.js +162 -0
- package/build/sfcc/register.js +73 -0
- package/build/sfcc/setup-status.js +114 -0
- package/build/sfcc/tool-wrapper.js +70 -0
- package/build/start-tickets-conductor.js +9 -1
- package/build/start-tickets.js +47 -4
- package/build/version.generated.js +1 -1
- package/package.json +7 -4
- package/pipelines/check-ci-ticket.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/learn-repository.json +84 -42
- package/smoke-test/SMOKE-TEST.md +11 -17
package/build/conductor/cli.js
CHANGED
|
@@ -336,13 +336,13 @@ export function parseEmitEventArgs(argv, deps = {}) {
|
|
|
336
336
|
return { input, json: bools.has("--json"), help: false };
|
|
337
337
|
}
|
|
338
338
|
/** Run the `emit-event` command. Prints the inserted event summary. */
|
|
339
|
-
export function runEmitEventCommand(argv, deps = {}) {
|
|
339
|
+
export async function runEmitEventCommand(argv, deps = {}) {
|
|
340
340
|
const parsed = parseEmitEventArgs(argv, deps);
|
|
341
341
|
if (parsed.help) {
|
|
342
342
|
console.log(getConductorUsage());
|
|
343
343
|
return 0;
|
|
344
344
|
}
|
|
345
|
-
const result = emitConductorEvent(parsed.input);
|
|
345
|
+
const result = await emitConductorEvent(parsed.input);
|
|
346
346
|
if (parsed.json) {
|
|
347
347
|
console.log(JSON.stringify(result));
|
|
348
348
|
}
|
|
@@ -442,13 +442,13 @@ export function parseSendMessageArgs(argv, deps = {}) {
|
|
|
442
442
|
* Run `send-message`. Prints compact JSON when `--json` is set; otherwise a
|
|
443
443
|
* sanitized human summary (message id / status / type only — NEVER the payload).
|
|
444
444
|
*/
|
|
445
|
-
export function runSendMessageCommand(argv, deps = {}) {
|
|
445
|
+
export async function runSendMessageCommand(argv, deps = {}) {
|
|
446
446
|
const parsed = parseSendMessageArgs(argv, deps);
|
|
447
447
|
if (parsed.help) {
|
|
448
448
|
console.log(getConductorUsage());
|
|
449
449
|
return 0;
|
|
450
450
|
}
|
|
451
|
-
const result = sendWorkerMessage(parsed.input);
|
|
451
|
+
const result = await sendWorkerMessage(parsed.input);
|
|
452
452
|
if (parsed.json) {
|
|
453
453
|
console.log(JSON.stringify(result));
|
|
454
454
|
}
|
|
@@ -495,13 +495,13 @@ export function parseCheckMessagesArgs(argv) {
|
|
|
495
495
|
* Run `check-messages`. Prints compact JSON when `--json` is set; otherwise a
|
|
496
496
|
* sanitized human summary (counts + per-message id/type only — NEVER payloads).
|
|
497
497
|
*/
|
|
498
|
-
export function runCheckMessagesCommand(argv) {
|
|
498
|
+
export async function runCheckMessagesCommand(argv) {
|
|
499
499
|
const parsed = parseCheckMessagesArgs(argv);
|
|
500
500
|
if (parsed.help) {
|
|
501
501
|
console.log(getConductorUsage());
|
|
502
502
|
return 0;
|
|
503
503
|
}
|
|
504
|
-
const result = checkWorkerMessages(parsed.input);
|
|
504
|
+
const result = await checkWorkerMessages(parsed.input);
|
|
505
505
|
if (parsed.json) {
|
|
506
506
|
console.log(JSON.stringify(result));
|
|
507
507
|
return 0;
|
|
@@ -590,7 +590,7 @@ export function parseGitHookArgs(argv) {
|
|
|
590
590
|
* warning) so a conductor producer failure never blocks the git commit/ref update
|
|
591
591
|
* the hook is attached to.
|
|
592
592
|
*/
|
|
593
|
-
export function runGitHookCommand(argv) {
|
|
593
|
+
export async function runGitHookCommand(argv) {
|
|
594
594
|
let parsed;
|
|
595
595
|
try {
|
|
596
596
|
parsed = parseGitHookArgs(argv);
|
|
@@ -602,7 +602,7 @@ export function runGitHookCommand(argv) {
|
|
|
602
602
|
}
|
|
603
603
|
try {
|
|
604
604
|
if (parsed.subcommand === "post-commit") {
|
|
605
|
-
runPostCommitHookProducer();
|
|
605
|
+
await runPostCommitHookProducer();
|
|
606
606
|
return 0;
|
|
607
607
|
}
|
|
608
608
|
// reference-transaction: read the captured updates from the stdin file.
|
|
@@ -625,7 +625,7 @@ export function runGitHookCommand(argv) {
|
|
|
625
625
|
}
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
|
-
runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
|
|
628
|
+
await runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
|
|
629
629
|
return 0;
|
|
630
630
|
}
|
|
631
631
|
catch {
|
|
@@ -996,13 +996,13 @@ export async function runSuperviseCommand(argv) {
|
|
|
996
996
|
return result.exit_code;
|
|
997
997
|
}
|
|
998
998
|
/** Run the explicit `purge` command. Prints deleted row counts. */
|
|
999
|
-
export function runPurgeCommand(argv) {
|
|
999
|
+
export async function runPurgeCommand(argv) {
|
|
1000
1000
|
const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
|
|
1001
1001
|
if (bools.has("--help")) {
|
|
1002
1002
|
console.log(getConductorUsage());
|
|
1003
1003
|
return 0;
|
|
1004
1004
|
}
|
|
1005
|
-
const result = purgeConductorLedger();
|
|
1005
|
+
const result = await purgeConductorLedger();
|
|
1006
1006
|
if (bools.has("--json")) {
|
|
1007
1007
|
console.log(JSON.stringify(result));
|
|
1008
1008
|
return 0;
|
|
@@ -1035,7 +1035,7 @@ export async function runConductorCli(argv) {
|
|
|
1035
1035
|
try {
|
|
1036
1036
|
switch (parsed.command) {
|
|
1037
1037
|
case "emit-event":
|
|
1038
|
-
return runEmitEventCommand(parsed.argv);
|
|
1038
|
+
return await runEmitEventCommand(parsed.argv);
|
|
1039
1039
|
case "supervise":
|
|
1040
1040
|
return await runSuperviseCommand(parsed.argv);
|
|
1041
1041
|
case "epic-tick":
|
|
@@ -1045,17 +1045,17 @@ export async function runConductorCli(argv) {
|
|
|
1045
1045
|
case "epic-status":
|
|
1046
1046
|
return await runEpicStatusCommand(parsed.argv);
|
|
1047
1047
|
case "send-message":
|
|
1048
|
-
return runSendMessageCommand(parsed.argv);
|
|
1048
|
+
return await runSendMessageCommand(parsed.argv);
|
|
1049
1049
|
case "check-messages":
|
|
1050
|
-
return runCheckMessagesCommand(parsed.argv);
|
|
1050
|
+
return await runCheckMessagesCommand(parsed.argv);
|
|
1051
1051
|
case "doctor":
|
|
1052
1052
|
return await runDoctorCommand(parsed.argv);
|
|
1053
1053
|
case "purge":
|
|
1054
|
-
return runPurgeCommand(parsed.argv);
|
|
1054
|
+
return await runPurgeCommand(parsed.argv);
|
|
1055
1055
|
case "install-git-hooks":
|
|
1056
1056
|
return runInstallGitHooksCommand(parsed.argv);
|
|
1057
1057
|
case "git-hook":
|
|
1058
|
-
return runGitHookCommand(parsed.argv);
|
|
1058
|
+
return await runGitHookCommand(parsed.argv);
|
|
1059
1059
|
default:
|
|
1060
1060
|
console.error('Error: Unknown command. Run "conductor --help" for usage.');
|
|
1061
1061
|
return 1;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* failure. This module performs NO writes: no hook installation, no schema
|
|
8
8
|
* migration, no event emission, and no scheduler unit creation.
|
|
9
9
|
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { doctorConductorLedger } from "./store.js";
|
|
11
12
|
import { inspectConductorGitHooks } from "./git-hooks.js";
|
|
12
13
|
/**
|
|
@@ -105,6 +106,65 @@ export function inspectMcpProfile(env, epicTick) {
|
|
|
105
106
|
}
|
|
106
107
|
return { resolved_profile, conductor_context_detected, degraded, warnings };
|
|
107
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Read-only probe of the local-merge (F4) capability. Runs `gh --version` and
|
|
111
|
+
* `gh auth status` (no writes, no merge) and reports whether the host could merge
|
|
112
|
+
* a PR locally if `policy_json.local_merge.enabled` were set. Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function inspectLocalMerge(runCommand) {
|
|
115
|
+
const run = runCommand ??
|
|
116
|
+
((cmd, args) => {
|
|
117
|
+
try {
|
|
118
|
+
// Static ESM import (this package is "type": "module"): a lazy
|
|
119
|
+
// require("child_process") throws ReferenceError under ESM, which the
|
|
120
|
+
// catch swallowed — making `gh` always look unavailable (false negative).
|
|
121
|
+
//
|
|
122
|
+
// `gh auth status` hits api.github.com; with no timeout a network stall
|
|
123
|
+
// (expired token, DNS hiccup, partition) blocks the doctor process until
|
|
124
|
+
// the OS TCP timeout fires (30s–2min). Bound it to 10s — a hung health
|
|
125
|
+
// check then simply reports `gh` unavailable. GH_PROMPT_DISABLED keeps an
|
|
126
|
+
// interactive prompt from holding the process past the timeout.
|
|
127
|
+
const r = spawnSync(cmd, args, {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
timeout: 10_000,
|
|
130
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1" },
|
|
131
|
+
});
|
|
132
|
+
return { status: r.status };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { status: null };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
let gh_available = false;
|
|
139
|
+
let gh_authed = false;
|
|
140
|
+
try {
|
|
141
|
+
gh_available = run("gh", ["--version"]).status === 0;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
gh_available = false;
|
|
145
|
+
}
|
|
146
|
+
if (gh_available) {
|
|
147
|
+
try {
|
|
148
|
+
gh_authed = run("gh", ["auth", "status"]).status === 0;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
gh_authed = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (!gh_available) {
|
|
156
|
+
warnings.push("`gh` is not installed or not on PATH. Local merge (policy_json.local_merge.enabled) " +
|
|
157
|
+
"cannot run; install the GitHub CLI to enable conductor-driven merges.");
|
|
158
|
+
}
|
|
159
|
+
else if (!gh_authed) {
|
|
160
|
+
warnings.push("`gh` is installed but not authenticated (`gh auth status` failed). Run `gh auth login` " +
|
|
161
|
+
"to grant the conductor merge permission; otherwise local merge will emit merge.failed/skip.");
|
|
162
|
+
}
|
|
163
|
+
// Capability gap only — local merge is opt-in, so an unauthed host is not a hard
|
|
164
|
+
// failure unless the operator enabled it. Reported as degraded for visibility.
|
|
165
|
+
const degraded = !gh_available || !gh_authed;
|
|
166
|
+
return { gh_available, gh_authed, degraded, warnings };
|
|
167
|
+
}
|
|
108
168
|
/**
|
|
109
169
|
* Build the combined read-only doctor report. Composes the existing ledger
|
|
110
170
|
* doctor, git hook inspection, and the epic-tick schedule enablement check.
|
|
@@ -116,10 +176,11 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
116
176
|
const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
|
|
117
177
|
const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
|
|
118
178
|
return {
|
|
119
|
-
ledger: doctorLedger(),
|
|
179
|
+
ledger: await doctorLedger(),
|
|
120
180
|
git_hooks: inspectHooks(deps.hooksDeps),
|
|
121
181
|
epic_tick: epicTick,
|
|
122
182
|
mcp_profile,
|
|
183
|
+
local_merge: inspectLocalMerge(deps.runCommand),
|
|
123
184
|
};
|
|
124
185
|
}
|
|
125
186
|
/**
|
|
@@ -128,7 +189,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
128
189
|
* status tags consistent with the git hooks section's visual hierarchy.
|
|
129
190
|
*/
|
|
130
191
|
export function formatConductorDoctorReport(report) {
|
|
131
|
-
const { ledger, git_hooks, epic_tick, mcp_profile } = report;
|
|
192
|
+
const { ledger, git_hooks, epic_tick, mcp_profile, local_merge } = report;
|
|
132
193
|
const lines = [
|
|
133
194
|
"Conductor ledger doctor",
|
|
134
195
|
"───────────────────────",
|
|
@@ -193,5 +254,21 @@ export function formatConductorDoctorReport(report) {
|
|
|
193
254
|
for (const w of mcp_profile.warnings)
|
|
194
255
|
lines.push(` - ${w}`);
|
|
195
256
|
}
|
|
257
|
+
lines.push("");
|
|
258
|
+
lines.push("Local merge capability (optional, opt-in)");
|
|
259
|
+
lines.push("─────────────────────────────────────────");
|
|
260
|
+
const ghTag = local_merge.gh_available
|
|
261
|
+
? local_merge.gh_authed
|
|
262
|
+
? "[OK]"
|
|
263
|
+
: "[WARNING] not authenticated"
|
|
264
|
+
: "[WARNING] not installed";
|
|
265
|
+
lines.push(`gh available: ${local_merge.gh_available} ${ghTag}`);
|
|
266
|
+
lines.push(`gh authenticated: ${local_merge.gh_authed}`);
|
|
267
|
+
lines.push(`degraded: ${local_merge.degraded}`);
|
|
268
|
+
if (local_merge.warnings.length > 0) {
|
|
269
|
+
lines.push("local merge warnings:");
|
|
270
|
+
for (const w of local_merge.warnings)
|
|
271
|
+
lines.push(` - ${w}`);
|
|
272
|
+
}
|
|
196
273
|
return lines.join("\n");
|
|
197
274
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* All durable mutations go through injected seams so the logic is testable
|
|
18
18
|
* without real network, ledger, or terminal access.
|
|
19
19
|
*/
|
|
20
|
-
import { computeReadySet, decideRemediation } from "./epic-state.js";
|
|
20
|
+
import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
|
|
21
21
|
import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// reconcileEpic
|
|
@@ -84,21 +84,26 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
84
84
|
const readySet = computeReadySet(plan, observed.ticket_statuses);
|
|
85
85
|
// Step 3: Dispatch each ready ticket idempotently.
|
|
86
86
|
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
87
|
+
// BAPI-445 — pre-implementation spec re-review sequencing gate (Phase 1). When
|
|
88
|
+
// `auto_rereview_enabled` is on and the review seam is wired, a `planned` ready
|
|
89
|
+
// ticket must first pass a spec re-review: this loop advances it
|
|
90
|
+
// planned → reviewing, spawns the review run under a `:review` dispatch role,
|
|
91
|
+
// correlates its run_id, and STOPS — implementation does NOT dispatch in the
|
|
92
|
+
// same tick (honouring the product directive: "wait for review to complete
|
|
93
|
+
// before implementation begins"). The reviewing → ready (or → blocked) fold
|
|
94
|
+
// happens on a later tick once the review completes/votes; only THEN does this
|
|
95
|
+
// loop dispatch implementation. Gating only on `planned` (not `ready`) is
|
|
96
|
+
// deliberate: a `ready` ticket already passed review (reviewing → ready) or the
|
|
97
|
+
// flag is off, so re-gating it would loop forever.
|
|
98
98
|
for (const ticketKey of readySet) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
const currentStatus = observed.ticket_statuses.get(ticketKey) ?? "planned";
|
|
100
|
+
if (supervisorConfig?.auto_rereview_enabled &&
|
|
101
|
+
deps.dispatchReviewSeam &&
|
|
102
|
+
currentStatus === "planned") {
|
|
103
|
+
await enterSpecReview(observed, deps, result, escalate, ticketKey);
|
|
104
|
+
// Either the review was dispatched, or a benign race/failure was logged;
|
|
105
|
+
// in every case implementation must NOT dispatch this tick.
|
|
106
|
+
continue;
|
|
102
107
|
}
|
|
103
108
|
let claimResult;
|
|
104
109
|
try {
|
|
@@ -155,6 +160,115 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
155
160
|
result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
156
161
|
}
|
|
157
162
|
}
|
|
163
|
+
// Step 3.2: Reviewing recovery (BAPI-445 Phase 1 re-entry). computeReadySet
|
|
164
|
+
// never surfaces a `reviewing` ticket (it is mid-flight, not ready), so a
|
|
165
|
+
// ticket whose spec-review run died before completing would be stranded
|
|
166
|
+
// forever without this pass. Iterate `reviewing` tickets explicitly (mirroring
|
|
167
|
+
// how the remediation pass iterates `blocked` tickets): if the review run is
|
|
168
|
+
// dead and the dedicated review-attempt cap is not yet exhausted, reclaim an
|
|
169
|
+
// attempt-scoped `:review` dispatch key and re-dispatch the review; once the
|
|
170
|
+
// cap is exhausted, escalate. This uses its OWN attempt counter — it never
|
|
171
|
+
// touches the BAPI-441 remediation budget.
|
|
172
|
+
if (supervisorConfig?.auto_rereview_enabled &&
|
|
173
|
+
deps.dispatchReviewSeam &&
|
|
174
|
+
deps.readReviewWorkerLiveness) {
|
|
175
|
+
const dispatchReviewSeam = deps.dispatchReviewSeam;
|
|
176
|
+
const readReviewWorkerLiveness = deps.readReviewWorkerLiveness;
|
|
177
|
+
const maxReviewAttempts = supervisorConfig.max_spec_review_attempts ?? DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS;
|
|
178
|
+
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
179
|
+
if (status !== "reviewing")
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
const liveness = await readReviewWorkerLiveness(observed.epic_key, ticketKey);
|
|
183
|
+
if (liveness.alive)
|
|
184
|
+
continue; // review still running — wait across ticks
|
|
185
|
+
const priorAttempts = deps.countReviewAttempts
|
|
186
|
+
? deps.countReviewAttempts(ticketKey)
|
|
187
|
+
: 0;
|
|
188
|
+
if (priorAttempts >= maxReviewAttempts) {
|
|
189
|
+
await escalate(observed.epic_key, `spec-review-liveness-exhausted:${ticketKey}`);
|
|
190
|
+
deps.log(`[epic-reconcile] spec-review liveness exhausted for ${ticketKey} ` +
|
|
191
|
+
`(attempts=${priorAttempts} max=${maxReviewAttempts}) → escalate`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Reclaim a fresh attempt-scoped review dispatch key. `priorAttempts`
|
|
195
|
+
// (>= 1 once the initial review dispatched) doubles as the next attempt
|
|
196
|
+
// index, so the key differs from every prior review run's key.
|
|
197
|
+
const attempt = priorAttempts;
|
|
198
|
+
let claim;
|
|
199
|
+
try {
|
|
200
|
+
claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", attempt);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
|
|
204
|
+
result.warnings.push(`review-reclaim-error for ${ticketKey}: ${safeMsg}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!claim.ok) {
|
|
208
|
+
result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (claim.kind !== "claimed") {
|
|
212
|
+
if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
|
|
213
|
+
result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
|
|
214
|
+
await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const dispatchKey = claim.dispatch.dispatch_key;
|
|
219
|
+
let runId;
|
|
220
|
+
try {
|
|
221
|
+
runId = await dispatchReviewSeam(observed.epic_key, ticketKey, attempt);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
|
|
225
|
+
result.warnings.push(`review-redispatch-failed for ${ticketKey}: ${safeMsg}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await deps.correlateRunId(dispatchKey, runId);
|
|
230
|
+
deps.log(`[epic-reconcile] re-dispatched spec re-review for ${ticketKey} ` +
|
|
231
|
+
`run_id=${runId} attempt=${attempt}`);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
|
|
235
|
+
result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "reviewing recovery error";
|
|
240
|
+
result.warnings.push(`reviewing-recovery-error for ${ticketKey}: ${safeMsg}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Step 3.4: Spec-review verdict gate (BAPI-445 Phase 2). A ticket blocked by a
|
|
245
|
+
// changes-requested SPEC review is a spec problem, not a code bug: escalate it
|
|
246
|
+
//
|
|
247
|
+
// NOTE: this block is reachable only once the verdict EMISSION primitive
|
|
248
|
+
// (`emitSpecReviewVerdict` in spec-review-producer.ts) is wired into the
|
|
249
|
+
// review-ticket completion flow so a `spec_review.changes_requested` event is
|
|
250
|
+
// folded to `blocked` with this reason. Until that Phase 2 integration lands,
|
|
251
|
+
// no ticket carries the `spec_review.changes_requested` blocked reason and this
|
|
252
|
+
// pass is a no-op. That is FAIL-SAFE: a completed review with no verdict does
|
|
253
|
+
// NOT advance `reviewing → ready` (epic-state filters review-run incidental
|
|
254
|
+
// terminals), so implementation never proceeds without an explicit pass —
|
|
255
|
+
// a stranded `reviewing` ticket is escalated by the recovery branch above.
|
|
256
|
+
// once for operator attention and EXCLUDE it from the BAPI-441 remediation pass
|
|
257
|
+
// below so it never spends the implementation remediation budget (no nudge, no
|
|
258
|
+
// redispatch, no budget counter increment). Runs independently of the
|
|
259
|
+
// remediation seams so a spec rejection is always surfaced.
|
|
260
|
+
const specRejectedTickets = new Set();
|
|
261
|
+
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
262
|
+
if (status !== "blocked")
|
|
263
|
+
continue;
|
|
264
|
+
if (observed.ticket_blocked_reasons?.get(ticketKey) !== "spec_review.changes_requested") {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
specRejectedTickets.add(ticketKey);
|
|
268
|
+
await escalate(observed.epic_key, `spec-review-rejected:${ticketKey}`);
|
|
269
|
+
deps.log(`[epic-reconcile] spec-review changes-requested for ${ticketKey} → escalate ` +
|
|
270
|
+
`(remediation budget bypassed)`);
|
|
271
|
+
}
|
|
158
272
|
// Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
|
|
159
273
|
// budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
|
|
160
274
|
// computeReadySet change (the ready-set still returns only planned tickets).
|
|
@@ -173,6 +287,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
173
287
|
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
174
288
|
if (status !== "blocked")
|
|
175
289
|
continue;
|
|
290
|
+
// BAPI-445: a spec-review rejection was already escalated in Step 3.4 and
|
|
291
|
+
// must never enter the implementation remediation budget. Skip it here.
|
|
292
|
+
if (specRejectedTickets.has(ticketKey))
|
|
293
|
+
continue;
|
|
176
294
|
// Per-ticket try/catch so a single ticket's failure never aborts the pass.
|
|
177
295
|
try {
|
|
178
296
|
const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
|
|
@@ -192,7 +310,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
192
310
|
const attemptKind = decision;
|
|
193
311
|
// The folding reason frames the nudge (message type + digest). Default to
|
|
194
312
|
// the review path when the ledger no longer carries the blocking event.
|
|
195
|
-
|
|
313
|
+
// spec_review.changes_requested is impossible here (skipped above) but
|
|
314
|
+
// narrow explicitly so the remediation seams keep their tight reason type.
|
|
315
|
+
const blockedReason = observed.ticket_blocked_reasons?.get(ticketKey);
|
|
316
|
+
const reason = blockedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
|
|
196
317
|
// A nudge needs a worker to address it to. The liveness scan already
|
|
197
318
|
// resolved the worker id from the same heartbeat that proved the worker
|
|
198
319
|
// alive; if it is missing we cannot relay, so skip BEFORE recording an
|
|
@@ -254,3 +375,79 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
254
375
|
// Step 5: Post-action waits are scheduled inline above per-dispatch
|
|
255
376
|
return result;
|
|
256
377
|
}
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// enterSpecReview (BAPI-445 Phase 1 — planned → reviewing + spawn review run)
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
/**
|
|
382
|
+
* Advance a `planned` ready ticket into the pre-implementation spec re-review:
|
|
383
|
+
* CAS planned → reviewing, claim the `:review` dispatch key, spawn the review
|
|
384
|
+
* run, and correlate its run_id. Every failure is absorbed as a per-ticket
|
|
385
|
+
* warning so the rest of the reconcile pass still runs.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately does NOT mutate the local `observed.ticket_statuses` map: the CAS
|
|
388
|
+
* is durable in Postgres (the NEXT tick's snapshot shows `reviewing`), and
|
|
389
|
+
* leaving the local status as `planned` keeps the same-tick reviewing-recovery
|
|
390
|
+
* pass from mistaking this freshly-spawned review for a stranded one and
|
|
391
|
+
* double-dispatching it.
|
|
392
|
+
*/
|
|
393
|
+
async function enterSpecReview(observed, deps, result, escalate, ticketKey) {
|
|
394
|
+
const dispatchReviewSeam = deps.dispatchReviewSeam;
|
|
395
|
+
if (!dispatchReviewSeam)
|
|
396
|
+
return; // caller-guarded; defensive
|
|
397
|
+
// CAS planned → reviewing (durable). Idempotency: if another tick already
|
|
398
|
+
// advanced the ticket, the CAS conflicts and we bail benignly.
|
|
399
|
+
const rowVersion = observed.ticket_row_versions.get(ticketKey) ?? 0;
|
|
400
|
+
let cas;
|
|
401
|
+
try {
|
|
402
|
+
cas = await deps.casTicketStatus(observed.epic_key, ticketKey, rowVersion, "reviewing", observed.plan_version);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "cas error";
|
|
406
|
+
result.warnings.push(`review-cas-error for ${ticketKey}: ${safeMsg}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!cas.ok) {
|
|
410
|
+
result.warnings.push(`review-cas-conflict advancing ${ticketKey} → reviewing`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Claim the :review dispatch key (attempt 0), then spawn + correlate.
|
|
414
|
+
let claim;
|
|
415
|
+
try {
|
|
416
|
+
claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", 0);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
|
|
420
|
+
result.warnings.push(`review-claim-error for ${ticketKey}: ${safeMsg}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!claim.ok) {
|
|
424
|
+
result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (claim.kind !== "claimed") {
|
|
428
|
+
if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
|
|
429
|
+
result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
|
|
430
|
+
await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const dispatchKey = claim.dispatch.dispatch_key;
|
|
435
|
+
let runId;
|
|
436
|
+
try {
|
|
437
|
+
runId = await dispatchReviewSeam(observed.epic_key, ticketKey, 0);
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
|
|
441
|
+
result.warnings.push(`review-dispatch-failed for ${ticketKey}: ${safeMsg}`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
await deps.correlateRunId(dispatchKey, runId);
|
|
446
|
+
deps.log(`[epic-reconcile] dispatched spec re-review for ${ticketKey} ` +
|
|
447
|
+
`run_id=${runId} (planned → reviewing; implementation deferred)`);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
|
|
451
|
+
result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
452
|
+
}
|
|
453
|
+
}
|