@bridge_gpt/mcp-server 0.2.16 → 0.2.18
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/build/agents.generated.js +2 -2
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +191 -11
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +11 -13
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +62 -13
- package/build/conductor/epic-runtime.js +447 -35
- package/build/conductor/epic-state.js +517 -63
- package/build/conductor/errors.js +41 -0
- package/build/conductor/event-accessors.js +234 -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 +13 -68
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-discovery.js +11 -1
- 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-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 +4 -0
- package/build/conductor-bin.js +2333 -666
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +32 -0
- package/build/index.js +10125 -8522
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/pipelines.generated.js +30 -5
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/start-tickets-conductor.js +22 -6
- package/build/start-tickets-prereqs.js +33 -3
- package/build/start-tickets.js +122 -22
- package/build/version.generated.js +1 -1
- package/package.json +5 -5
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3272 -1
- package/public/css/main.min.css.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -2
|
@@ -74,16 +74,174 @@ export function buildConductorJiraUrl(baseUrl, apiPath, params = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
return url.toString();
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
const CONDUCTOR_BRIDGE_API_ERROR_KINDS = [
|
|
78
|
+
"invalid-input",
|
|
79
|
+
"network",
|
|
80
|
+
"timeout",
|
|
81
|
+
"unauthorized",
|
|
82
|
+
"server",
|
|
83
|
+
"http",
|
|
84
|
+
];
|
|
85
|
+
/** Max length of the sanitized body preview embedded in error diagnostics. */
|
|
86
|
+
const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
|
|
87
|
+
/**
|
|
88
|
+
* Redact obvious secret-shaped tokens from a diagnostic preview string so a
|
|
89
|
+
* backend message that happens to echo a token/header never reaches a log line.
|
|
90
|
+
*/
|
|
91
|
+
function redactErrorPreview(text) {
|
|
92
|
+
return text
|
|
93
|
+
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
|
|
94
|
+
.replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]");
|
|
95
|
+
}
|
|
96
|
+
/** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
|
|
97
|
+
function boundedErrorPreview(text) {
|
|
98
|
+
const redacted = redactErrorPreview(text).replace(/\s+/g, " ").trim();
|
|
99
|
+
return redacted.length > CONDUCTOR_ERROR_PREVIEW_MAX
|
|
100
|
+
? `${redacted.slice(0, CONDUCTOR_ERROR_PREVIEW_MAX)}…`
|
|
101
|
+
: redacted;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract sanitized diagnostics from a parsed backend error body. Recognizes the
|
|
105
|
+
* FastAPI/backend shapes `{ detail: { error_code, message } }`,
|
|
106
|
+
* `{ detail: "..." }`, and `{ error_code, message }`; any other shape yields a
|
|
107
|
+
* bounded stringified preview. Never throws and never returns raw/unbounded text.
|
|
108
|
+
*/
|
|
109
|
+
export function extractSanitizedErrorDiagnostics(body) {
|
|
110
|
+
if (typeof body === "string") {
|
|
111
|
+
const trimmed = body.trim();
|
|
112
|
+
return trimmed ? { bodyPreview: boundedErrorPreview(trimmed) } : {};
|
|
113
|
+
}
|
|
114
|
+
if (!body || typeof body !== "object") {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
const record = body;
|
|
118
|
+
const detail = record["detail"];
|
|
119
|
+
let errorCode;
|
|
120
|
+
let message;
|
|
121
|
+
if (detail && typeof detail === "object") {
|
|
122
|
+
const d = detail;
|
|
123
|
+
if (typeof d["error_code"] === "string")
|
|
124
|
+
errorCode = d["error_code"];
|
|
125
|
+
if (typeof d["message"] === "string")
|
|
126
|
+
message = d["message"];
|
|
127
|
+
}
|
|
128
|
+
else if (typeof detail === "string") {
|
|
129
|
+
message = detail;
|
|
130
|
+
}
|
|
131
|
+
if (!errorCode && typeof record["error_code"] === "string") {
|
|
132
|
+
errorCode = record["error_code"];
|
|
133
|
+
}
|
|
134
|
+
if (!message && typeof record["message"] === "string") {
|
|
135
|
+
message = record["message"];
|
|
136
|
+
}
|
|
137
|
+
const diagnostics = {};
|
|
138
|
+
if (errorCode)
|
|
139
|
+
diagnostics.errorCode = boundedErrorPreview(errorCode);
|
|
140
|
+
if (message)
|
|
141
|
+
diagnostics.bodyPreview = boundedErrorPreview(message);
|
|
142
|
+
return diagnostics;
|
|
143
|
+
}
|
|
144
|
+
/** Redact exact secret substrings (e.g. the API key from the request headers). */
|
|
145
|
+
function redactDiagnosticValues(diagnostics, secrets) {
|
|
146
|
+
const scrub = (text) => {
|
|
147
|
+
let out = text;
|
|
148
|
+
for (const secret of secrets) {
|
|
149
|
+
if (secret && secret.length >= 4)
|
|
150
|
+
out = out.split(secret).join("[REDACTED]");
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
const out = {};
|
|
155
|
+
if (diagnostics.errorCode)
|
|
156
|
+
out.errorCode = scrub(diagnostics.errorCode);
|
|
157
|
+
if (diagnostics.bodyPreview)
|
|
158
|
+
out.bodyPreview = scrub(diagnostics.bodyPreview);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Best-effort read of a non-2xx response body into sanitized diagnostics. Never
|
|
163
|
+
* throws — a missing/invalid/non-JSON body simply yields `{}`. Any auth-header
|
|
164
|
+
* values in `headers` (e.g. `X-API-Key`) are additionally redacted from the
|
|
165
|
+
* preview so a body that echoes the credential can never surface it.
|
|
166
|
+
*/
|
|
167
|
+
async function readSanitizedErrorDiagnostics(resp, headers = {}) {
|
|
168
|
+
try {
|
|
169
|
+
const diagnostics = extractSanitizedErrorDiagnostics(await resp.json());
|
|
170
|
+
const secrets = Object.entries(headers)
|
|
171
|
+
.filter(([k]) => /key|authorization|token/i.test(k))
|
|
172
|
+
.map(([, v]) => v);
|
|
173
|
+
return redactDiagnosticValues(diagnostics, secrets);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Sanitized HTTP error for conductor Bridge API calls — never leaks secrets.
|
|
181
|
+
*
|
|
182
|
+
* Construction is backward-compatible in two forms:
|
|
183
|
+
* - `new ConductorBridgeApiError(kind, status?, diagnostics?)` — the canonical
|
|
184
|
+
* form; builds a message from the coarse kind plus optional HTTP status,
|
|
185
|
+
* backend `error_code`, and a bounded secret-redacted message preview.
|
|
186
|
+
* - `new ConductorBridgeApiError("some legacy message")` — a bare message
|
|
187
|
+
* string (not one of the known kinds) is preserved verbatim as the error
|
|
188
|
+
* message, with `kind` defaulting to `"http"` and no diagnostic fields set.
|
|
189
|
+
*/
|
|
78
190
|
export class ConductorBridgeApiError extends Error {
|
|
79
191
|
kind;
|
|
80
192
|
status;
|
|
81
|
-
|
|
82
|
-
|
|
193
|
+
errorCode;
|
|
194
|
+
bodyPreview;
|
|
195
|
+
constructor(kindOrMessage, status, diagnostics) {
|
|
196
|
+
const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
|
|
197
|
+
const errorCode = diagnostics?.errorCode;
|
|
198
|
+
const bodyPreview = diagnostics?.bodyPreview;
|
|
199
|
+
if (isKnownKind) {
|
|
200
|
+
const parts = [
|
|
201
|
+
`Conductor Bridge API request failed (${kindOrMessage}${typeof status === "number" ? `, status ${status}` : ""})`,
|
|
202
|
+
];
|
|
203
|
+
if (errorCode)
|
|
204
|
+
parts.push(`code=${errorCode}`);
|
|
205
|
+
if (bodyPreview)
|
|
206
|
+
parts.push(bodyPreview);
|
|
207
|
+
super(parts.join(": "));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Legacy/backward-compatible bare-message construction.
|
|
211
|
+
super(kindOrMessage);
|
|
212
|
+
}
|
|
83
213
|
this.name = "ConductorBridgeApiError";
|
|
84
|
-
this.kind =
|
|
85
|
-
|
|
214
|
+
this.kind = (isKnownKind ? kindOrMessage : "http");
|
|
215
|
+
if (typeof status === "number")
|
|
216
|
+
this.status = status;
|
|
217
|
+
if (errorCode)
|
|
218
|
+
this.errorCode = errorCode;
|
|
219
|
+
if (bodyPreview)
|
|
220
|
+
this.bodyPreview = bodyPreview;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Build a bounded, secret-free diagnostic string from an unknown thrown error
|
|
225
|
+
* for tick warnings/logs. For a {@link ConductorBridgeApiError} it surfaces the
|
|
226
|
+
* coarse kind, HTTP status, backend error code, and sanitized message preview
|
|
227
|
+
* (never a stack, raw body, or secret). For any other Error it returns the
|
|
228
|
+
* constructor name; non-Errors fall back to `fallback`.
|
|
229
|
+
*/
|
|
230
|
+
export function safeDiagnosticMessage(err, fallback) {
|
|
231
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
232
|
+
const parts = [`kind=${err.kind}`];
|
|
233
|
+
if (typeof err.status === "number")
|
|
234
|
+
parts.push(`status=${err.status}`);
|
|
235
|
+
if (err.errorCode)
|
|
236
|
+
parts.push(`code=${err.errorCode}`);
|
|
237
|
+
if (err.bodyPreview)
|
|
238
|
+
parts.push(err.bodyPreview);
|
|
239
|
+
return parts.join(" ");
|
|
240
|
+
}
|
|
241
|
+
if (err instanceof Error) {
|
|
242
|
+
return err.constructor.name;
|
|
86
243
|
}
|
|
244
|
+
return fallback;
|
|
87
245
|
}
|
|
88
246
|
/** GET auth headers. The API key travels ONLY in a header, never in the URL. */
|
|
89
247
|
function conductorGetHeaders(access) {
|
|
@@ -108,13 +266,14 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
|
|
|
108
266
|
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
109
267
|
}
|
|
110
268
|
if (!resp.ok) {
|
|
269
|
+
const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
|
|
111
270
|
if (resp.status === 401 || resp.status === 403) {
|
|
112
|
-
throw new ConductorBridgeApiError("unauthorized", resp.status);
|
|
271
|
+
throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
|
|
113
272
|
}
|
|
114
273
|
if (resp.status >= 500) {
|
|
115
|
-
throw new ConductorBridgeApiError("server", resp.status);
|
|
274
|
+
throw new ConductorBridgeApiError("server", resp.status, diagnostics);
|
|
116
275
|
}
|
|
117
|
-
throw new ConductorBridgeApiError("http", resp.status);
|
|
276
|
+
throw new ConductorBridgeApiError("http", resp.status, diagnostics);
|
|
118
277
|
}
|
|
119
278
|
try {
|
|
120
279
|
return await resp.json();
|
|
@@ -252,13 +411,14 @@ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body
|
|
|
252
411
|
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
253
412
|
}
|
|
254
413
|
if (!resp.ok) {
|
|
414
|
+
const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
|
|
255
415
|
if (resp.status === 401 || resp.status === 403) {
|
|
256
|
-
throw new ConductorBridgeApiError("unauthorized", resp.status);
|
|
416
|
+
throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
|
|
257
417
|
}
|
|
258
418
|
if (resp.status >= 500) {
|
|
259
|
-
throw new ConductorBridgeApiError("server", resp.status);
|
|
419
|
+
throw new ConductorBridgeApiError("server", resp.status, diagnostics);
|
|
260
420
|
}
|
|
261
|
-
throw new ConductorBridgeApiError("http", resp.status);
|
|
421
|
+
throw new ConductorBridgeApiError("http", resp.status, diagnostics);
|
|
262
422
|
}
|
|
263
423
|
try {
|
|
264
424
|
return await resp.json();
|
|
@@ -508,6 +668,26 @@ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch)
|
|
|
508
668
|
}
|
|
509
669
|
return [];
|
|
510
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
|
|
673
|
+
* lifecycle status. Drives the same backend `update_epic_run` CAS path the
|
|
674
|
+
* manual close-out used: passing `expectedStatus` makes the transition a safe
|
|
675
|
+
* compare-and-swap (a stale status surfaces as a sanitized
|
|
676
|
+
* {@link ConductorBridgeApiError} — HTTP 400 VALIDATION — rather than clobbering
|
|
677
|
+
* a concurrent terminal transition). Prefer passing the concrete run UUID as
|
|
678
|
+
* `epicKey` when the runtime has it. Returns the normalized {@link EpicRunRecord}.
|
|
679
|
+
*/
|
|
680
|
+
export async function updateEpicRunStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
681
|
+
requireNonEmptyString(request.epicKey);
|
|
682
|
+
const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicKey));
|
|
683
|
+
const body = JSON.stringify({
|
|
684
|
+
repo_name: access.repoName,
|
|
685
|
+
status: request.status,
|
|
686
|
+
...(request.expectedStatus ? { expected_status: request.expectedStatus } : {}),
|
|
687
|
+
});
|
|
688
|
+
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
689
|
+
return parsed;
|
|
690
|
+
}
|
|
511
691
|
// ---------------------------------------------------------------------------
|
|
512
692
|
// Per-ticket CAS status advancement
|
|
513
693
|
// ---------------------------------------------------------------------------
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is a dependency-light command-hook entrypoint registered into a spawned
|
|
5
5
|
* Claude worker's `.claude/settings.local.json` by `start-tickets`. Claude Code
|
|
6
|
-
* invokes it for lifecycle events (SessionStart,
|
|
7
|
-
*
|
|
6
|
+
* invokes it for lifecycle events (SessionStart, SessionEnd, Notification,
|
|
7
|
+
* optionally PreToolUse), passing the native hook JSON on stdin. `Stop` and
|
|
8
|
+
* `SubagentStop` are intentionally NOT registered/emitted: they are per-turn /
|
|
9
|
+
* per-subagent events, not session-terminal (BAPI-507 N-1); only `SessionEnd`
|
|
10
|
+
* is a true session end and maps to `run.stopped`.
|
|
8
11
|
* The writer:
|
|
9
12
|
*
|
|
10
13
|
* 1. reads + parses the native Claude hook payload from stdin,
|
|
@@ -50,15 +53,30 @@ export function resolveClaudeHookEventName(payload) {
|
|
|
50
53
|
/**
|
|
51
54
|
* Map a Claude lifecycle event name to a canonical conductor semantic type.
|
|
52
55
|
* Unknown events map to `null` (the caller then skips emission).
|
|
56
|
+
*
|
|
57
|
+
* BAPI-507 (N-1): `Stop` and `SubagentStop` are intentionally ignored (→ null).
|
|
58
|
+
* Claude Code fires `Stop` at the end of EACH assistant turn and `SubagentStop`
|
|
59
|
+
* at each Task-subagent completion — NOT only at session termination. Mapping
|
|
60
|
+
* them to `run.stopped` folded a ticket to `ready_for_review` from minutes into
|
|
61
|
+
* the session, while the worker was still implementing with no branch pushed and
|
|
62
|
+
* no PR. The only true session-terminal event is `SessionEnd`, which maps to
|
|
63
|
+
* `run.stopped`. (Verified against the Claude Code lifecycle-hook contract:
|
|
64
|
+
* `SessionEnd` fires once when the session ends; `Stop`/`SubagentStop` are
|
|
65
|
+
* per-turn/per-subagent.)
|
|
53
66
|
*/
|
|
54
67
|
export function mapClaudeHookEventToSemanticType(eventName) {
|
|
55
68
|
switch (eventName) {
|
|
56
69
|
case "SessionStart":
|
|
57
70
|
return "run.started";
|
|
58
|
-
case "
|
|
71
|
+
case "SessionEnd":
|
|
72
|
+
// The one true session-terminal event: the worker's session has ended.
|
|
59
73
|
return "run.stopped";
|
|
74
|
+
case "Stop":
|
|
75
|
+
// Per-turn boundary, NOT session end — deliberately ignored (BAPI-507 N-1).
|
|
76
|
+
return null;
|
|
60
77
|
case "SubagentStop":
|
|
61
|
-
|
|
78
|
+
// Per-subagent completion, NOT session end — deliberately ignored.
|
|
79
|
+
return null;
|
|
62
80
|
case "Notification":
|
|
63
81
|
return "agent.notification";
|
|
64
82
|
case "PreToolUse":
|
package/build/conductor/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js"
|
|
|
18
18
|
import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
|
|
19
19
|
import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
|
|
20
20
|
import { installConductorGitHooks } from "./git-hooks.js";
|
|
21
|
+
import { runFileScopeGuardCli } from "./file-scope-guard.js";
|
|
21
22
|
import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
|
|
22
23
|
import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
|
|
23
24
|
import { resolveSupervisorConfig } from "./supervisor-config.js";
|
|
@@ -46,14 +47,14 @@ export function getConductorUsage() {
|
|
|
46
47
|
" git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
|
|
47
48
|
" git-hook reference-transaction --phase <p> --stdin-file <f>",
|
|
48
49
|
" Run the reference-transaction producer (invoked by the hook)",
|
|
50
|
+
" file-scope-guard Warn-only: compare the branch diff against the declared",
|
|
51
|
+
" touched-file set (always exits 0; never blocks a PR)",
|
|
49
52
|
"",
|
|
50
53
|
"supervise options:",
|
|
51
54
|
" --run-id <id> Run/session identifier to supervise (required)",
|
|
52
55
|
" --wake-interval-ms <n> Deterministic event-poll cadence (clamped 30000..60000)",
|
|
53
56
|
" --global-timeout-ms <n> Total wall-clock ceiling for the run",
|
|
54
|
-
" --llm-budget-calls <n> Max LLM judgment calls per run before degraded-only mode",
|
|
55
57
|
" --escalation-cooldown-ms <n> Min gap between escalations for the same worker+reason",
|
|
56
|
-
" --no-llm Deterministic-only mode (never call the LLM judgment boundary)",
|
|
57
58
|
"",
|
|
58
59
|
"install-git-hooks notes:",
|
|
59
60
|
" Hooks are LOCAL, unversioned, opportunistic, and bypassable. Missing hooks are a",
|
|
@@ -151,6 +152,7 @@ const VALID_COMMANDS = new Set([
|
|
|
151
152
|
"purge",
|
|
152
153
|
"install-git-hooks",
|
|
153
154
|
"git-hook",
|
|
155
|
+
"file-scope-guard",
|
|
154
156
|
]);
|
|
155
157
|
/**
|
|
156
158
|
* Parse the top-level conductor argv into a subcommand (without a CLI
|
|
@@ -925,10 +927,9 @@ const SUPERVISE_VALUE_FLAGS = new Set([
|
|
|
925
927
|
"--run-id",
|
|
926
928
|
"--wake-interval-ms",
|
|
927
929
|
"--global-timeout-ms",
|
|
928
|
-
"--llm-budget-calls",
|
|
929
930
|
"--escalation-cooldown-ms",
|
|
930
931
|
]);
|
|
931
|
-
const SUPERVISE_BOOL_FLAGS = new Set(["--
|
|
932
|
+
const SUPERVISE_BOOL_FLAGS = new Set(["--help"]);
|
|
932
933
|
/** Parse a required positive-integer flag, raising a sanitized error otherwise. */
|
|
933
934
|
function parsePositiveIntFlag(values, flag) {
|
|
934
935
|
const raw = values.get(flag);
|
|
@@ -946,9 +947,8 @@ function parsePositiveIntFlag(values, flag) {
|
|
|
946
947
|
/**
|
|
947
948
|
* Parse `supervise` flags. `--run-id` is required and must be non-empty /
|
|
948
949
|
* non-whitespace. Numeric overrides are validated as non-negative integers (the
|
|
949
|
-
* config resolver clamps them to safe bounds)
|
|
950
|
-
*
|
|
951
|
-
* {@link ConductorValidationError}.
|
|
950
|
+
* config resolver clamps them to safe bounds). Malformed input raises a
|
|
951
|
+
* sanitized {@link ConductorValidationError}.
|
|
952
952
|
*/
|
|
953
953
|
export function parseSuperviseArgs(argv) {
|
|
954
954
|
const { values, bools } = tokenizeFlags(argv, SUPERVISE_VALUE_FLAGS, SUPERVISE_BOOL_FLAGS);
|
|
@@ -966,14 +966,9 @@ export function parseSuperviseArgs(argv) {
|
|
|
966
966
|
const globalTimeout = parsePositiveIntFlag(values, "--global-timeout-ms");
|
|
967
967
|
if (globalTimeout !== undefined)
|
|
968
968
|
overrides.global_timeout_ms = globalTimeout;
|
|
969
|
-
const llmCalls = parsePositiveIntFlag(values, "--llm-budget-calls");
|
|
970
|
-
if (llmCalls !== undefined)
|
|
971
|
-
overrides.llm_max_calls = llmCalls;
|
|
972
969
|
const cooldown = parsePositiveIntFlag(values, "--escalation-cooldown-ms");
|
|
973
970
|
if (cooldown !== undefined)
|
|
974
971
|
overrides.escalation_cooldown_ms = cooldown;
|
|
975
|
-
if (bools.has("--no-llm"))
|
|
976
|
-
overrides.llm_enabled = false;
|
|
977
972
|
return { runId: runIdRaw.trim(), overrides, help: false };
|
|
978
973
|
}
|
|
979
974
|
/**
|
|
@@ -990,7 +985,7 @@ export async function runSuperviseCommand(argv) {
|
|
|
990
985
|
}
|
|
991
986
|
const config = resolveSupervisorConfig(parsed.overrides);
|
|
992
987
|
console.log(`[supervisor] starting run=${parsed.runId} wake=${config.wake_interval_ms}ms ` +
|
|
993
|
-
`global_timeout=${config.global_timeout_ms}ms
|
|
988
|
+
`global_timeout=${config.global_timeout_ms}ms`);
|
|
994
989
|
const { runSupervisor } = await import("./supervisor-runtime.js");
|
|
995
990
|
const result = await runSupervisor({ run_id: parsed.runId, config });
|
|
996
991
|
return result.exit_code;
|
|
@@ -1056,6 +1051,9 @@ export async function runConductorCli(argv) {
|
|
|
1056
1051
|
return runInstallGitHooksCommand(parsed.argv);
|
|
1057
1052
|
case "git-hook":
|
|
1058
1053
|
return await runGitHookCommand(parsed.argv);
|
|
1054
|
+
case "file-scope-guard":
|
|
1055
|
+
// BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
|
|
1056
|
+
return runFileScopeGuardCli();
|
|
1059
1057
|
default:
|
|
1060
1058
|
console.error('Error: Unknown command. Run "conductor --help" for usage.');
|
|
1061
1059
|
return 1;
|
|
@@ -403,10 +403,15 @@ export function normalizeReviewSnapshot(raw) {
|
|
|
403
403
|
const headSha = typeof detail.head_sha === "string" && detail.head_sha.trim().length > 0
|
|
404
404
|
? detail.head_sha.trim()
|
|
405
405
|
: null;
|
|
406
|
+
// BAPI-493 (D-5): head-scope the dedupe hash so two otherwise-identical review
|
|
407
|
+
// snapshots observed on DIFFERENT PR heads hash differently (the producer dedupe
|
|
408
|
+
// dimension was not head-scoped before). Uses the already-normalized local
|
|
409
|
+
// `headSha` (trimmed string or null) so a missing head stays deterministic.
|
|
406
410
|
const reviewStateHash = stableJsonHash({
|
|
407
411
|
review_decision: reviewDecision,
|
|
408
412
|
approvals,
|
|
409
413
|
sticky_verdict: stickyVerdict,
|
|
414
|
+
head_sha: headSha,
|
|
410
415
|
});
|
|
411
416
|
return { review_decision: reviewDecision, approvals, sticky_verdict: stickyVerdict, head_sha: headSha, review_state_hash: reviewStateHash };
|
|
412
417
|
}
|
|
@@ -19,6 +19,21 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
|
|
21
21
|
import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
22
|
+
/**
|
|
23
|
+
* Sanitize a thrown value into a single-line, bounded diagnostic string for a
|
|
24
|
+
* tick warning (BAPI-487 Requirement 2). Surfaces the real `err.message` (the
|
|
25
|
+
* remediation messages — `remediate: no PR binding for <KEY>`, `remediate: no
|
|
26
|
+
* head_sha for PR <N>` — are safe, secret-free text) instead of the opaque
|
|
27
|
+
* constructor name that hid the true failure for an entire smoke test. Collapses
|
|
28
|
+
* whitespace (defeating multiline log injection), caps length, and falls back to
|
|
29
|
+
* the constructor name for an empty message or `fallback` for a non-Error throw.
|
|
30
|
+
*/
|
|
31
|
+
export function safeDiagnosticMessage(err, fallback) {
|
|
32
|
+
const raw = err instanceof Error
|
|
33
|
+
? (err.message.trim() || err.constructor.name)
|
|
34
|
+
: fallback;
|
|
35
|
+
return raw.replace(/\s+/g, " ").slice(0, 500);
|
|
36
|
+
}
|
|
22
37
|
// ---------------------------------------------------------------------------
|
|
23
38
|
// reconcileEpic
|
|
24
39
|
// ---------------------------------------------------------------------------
|
|
@@ -43,10 +58,23 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
43
58
|
catch (err) {
|
|
44
59
|
const safeMsg = err instanceof Error ? err.constructor.name : "cas error";
|
|
45
60
|
result.warnings.push(`cas-error folding ${signal.signal_type} for ${signal.ticket_key}: ${safeMsg}`);
|
|
61
|
+
// BAPI-500: the fold CAS did not durably run, so any PREDICTED post-fold
|
|
62
|
+
// row version for this ticket is unconfirmed. Clear it so the same-pass
|
|
63
|
+
// remediation CAS falls back to the tick-start snapshot instead of trusting
|
|
64
|
+
// a snapshot+1 that was never written.
|
|
65
|
+
observed.ticket_post_fold_row_versions?.delete(signal.ticket_key);
|
|
46
66
|
continue;
|
|
47
67
|
}
|
|
48
68
|
if (casResult.ok) {
|
|
49
69
|
result.signals_folded += 1;
|
|
70
|
+
// BAPI-500: confirm the post-fold row version from the ACTUAL CAS response
|
|
71
|
+
// (never the tick-start snapshot or a re-derived `+ 1`), so a follow-on
|
|
72
|
+
// remediation CAS in this same pass CASes against the durable snapshot+1.
|
|
73
|
+
// Keyed per ticket — one status CAS per ticket per tick. Do NOT touch
|
|
74
|
+
// `observed.ticket_row_versions`; it stays the tick-start snapshot so
|
|
75
|
+
// callers can distinguish fallback values from same-pass post-fold values.
|
|
76
|
+
observed.ticket_post_fold_row_versions ??= new Map();
|
|
77
|
+
observed.ticket_post_fold_row_versions.set(signal.ticket_key, casResult.ticket_status.row_version);
|
|
50
78
|
deps.log(`[epic-reconcile] folded ${signal.signal_type} for ${signal.ticket_key} → ${signal.next_status}`);
|
|
51
79
|
// BAPI-442: fire teardown + Jira transition strictly after merge.succeeded
|
|
52
80
|
// CAS → done. Both are fail-open: errors are logged and never abort the pass.
|
|
@@ -66,18 +94,25 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
66
94
|
}
|
|
67
95
|
}
|
|
68
96
|
else {
|
|
69
|
-
// CAS conflict: another tick already advanced this ticket — non-fatal
|
|
97
|
+
// CAS conflict: another tick already advanced this ticket — non-fatal.
|
|
98
|
+
// BAPI-500: the predicted post-fold version is unconfirmed (the real row
|
|
99
|
+
// version is whatever the other writer left), so clear it and let the
|
|
100
|
+
// same-pass remediation CAS fall back to the tick-start snapshot.
|
|
101
|
+
observed.ticket_post_fold_row_versions?.delete(signal.ticket_key);
|
|
70
102
|
result.warnings.push(`cas-conflict folding ${signal.signal_type} for ${signal.ticket_key}`);
|
|
71
103
|
}
|
|
72
104
|
}
|
|
73
|
-
// Step 1.5: Seed planned status rows for every plan node
|
|
105
|
+
// Step 1.5: Seed planned status rows for every plan node. Idempotent: the
|
|
106
|
+
// backend read-back no-ops a re-seed of an existing row (A6/BAPI-507), so a
|
|
107
|
+
// normal multi-tick run no longer produces per-node seed warnings. When a seed
|
|
108
|
+
// DOES fail, the warning carries safe backend diagnostics (status/code/message
|
|
109
|
+
// preview) rather than the opaque `ConductorBridgeApiError` constructor name.
|
|
74
110
|
for (const ticket of plan.tickets) {
|
|
75
111
|
try {
|
|
76
112
|
await deps.seedTicketStatus(observed.epic_key, ticket.ticket_key, plan.plan_version);
|
|
77
113
|
}
|
|
78
114
|
catch (err) {
|
|
79
|
-
|
|
80
|
-
result.warnings.push(`seed-error for ${ticket.ticket_key}: ${safeMsg}`);
|
|
115
|
+
result.warnings.push(`seed-error for ${ticket.ticket_key}: ${safeDiagnosticMessage(err, "seed error")}`);
|
|
81
116
|
}
|
|
82
117
|
}
|
|
83
118
|
// Step 2: Compute the ready-set (pure — never calls LLM)
|
|
@@ -307,18 +342,30 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
307
342
|
}
|
|
308
343
|
// The attempt being recorded is the next one (1-based).
|
|
309
344
|
const attempt = counters.attempts + 1;
|
|
310
|
-
const attemptKind = decision;
|
|
311
345
|
// The folding reason frames the nudge (message type + digest). Default to
|
|
312
346
|
// the review path when the ledger no longer carries the blocking event.
|
|
313
347
|
// spec_review.changes_requested is impossible here (skipped above) but
|
|
314
348
|
// narrow explicitly so the remediation seams keep their tight reason type.
|
|
349
|
+
// BAPI-494: `merge.conflict` is normalized as a first-class reason.
|
|
315
350
|
const blockedReason = observed.ticket_blocked_reasons?.get(ticketKey);
|
|
316
|
-
const
|
|
351
|
+
const normalizedReason = blockedReason === "ci.failed"
|
|
352
|
+
? "ci.failed"
|
|
353
|
+
: blockedReason === "merge.conflict"
|
|
354
|
+
? "merge.conflict"
|
|
355
|
+
: "review.changes_requested";
|
|
356
|
+
// BAPI-494: a conflict ALWAYS redispatches a resume-mode worker (it needs a
|
|
357
|
+
// working tree to rebase + resolve, not a message), regardless of whether the
|
|
358
|
+
// original worker is still alive — so the alive-worker nudge is forced to a
|
|
359
|
+
// redispatch here. Budget-exhaustion escalation already fired above, so at this
|
|
360
|
+
// point the budget is not exhausted. CI/review keep their liveness-driven
|
|
361
|
+
// nudge-vs-redispatch decision unchanged.
|
|
362
|
+
const attemptKind = normalizedReason === "merge.conflict" ? "redispatch" : decision;
|
|
317
363
|
// A nudge needs a worker to address it to. The liveness scan already
|
|
318
364
|
// resolved the worker id from the same heartbeat that proved the worker
|
|
319
365
|
// alive; if it is missing we cannot relay, so skip BEFORE recording an
|
|
320
366
|
// attempt — otherwise the CAS would burn a budget unit with nothing sent.
|
|
321
|
-
|
|
367
|
+
// A redispatch (including every conflict) needs no worker id, so it is exempt.
|
|
368
|
+
if (attemptKind === "nudge" && !liveness.workerId) {
|
|
322
369
|
result.warnings.push(`remediation nudge skipped for ${ticketKey}: alive worker has no worker_id`);
|
|
323
370
|
continue;
|
|
324
371
|
}
|
|
@@ -329,10 +376,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
329
376
|
// the pass still runs (crash-replay safe).
|
|
330
377
|
let casOutcome;
|
|
331
378
|
try {
|
|
332
|
-
casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind,
|
|
379
|
+
casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, normalizedReason);
|
|
333
380
|
}
|
|
334
381
|
catch (err) {
|
|
335
|
-
const safeMsg = err
|
|
382
|
+
const safeMsg = safeDiagnosticMessage(err, "remediate error");
|
|
336
383
|
result.warnings.push(`remediate-cas-failed for ${ticketKey} (${attemptKind}): ${safeMsg}`);
|
|
337
384
|
continue;
|
|
338
385
|
}
|
|
@@ -342,16 +389,18 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
342
389
|
result.warnings.push(`remediation replay swallowed for ${ticketKey} (${attemptKind})`);
|
|
343
390
|
continue;
|
|
344
391
|
}
|
|
345
|
-
if (
|
|
346
|
-
|
|
392
|
+
if (attemptKind === "nudge") {
|
|
393
|
+
// Nudge is CI/review only (conflict never nudges), so narrow the reason.
|
|
394
|
+
const nudgeReason = normalizedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
|
|
395
|
+
await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, nudgeReason, liveness.workerId);
|
|
347
396
|
}
|
|
348
397
|
else {
|
|
349
398
|
await resumeDispatch(observed.epic_key, ticketKey, attempt);
|
|
350
399
|
}
|
|
351
|
-
deps.log(`[epic-reconcile] remediation ${
|
|
400
|
+
deps.log(`[epic-reconcile] remediation ${attemptKind} ${ticketKey} attempt=${attempt} reason=${normalizedReason}`);
|
|
352
401
|
}
|
|
353
402
|
catch (err) {
|
|
354
|
-
const safeMsg = err
|
|
403
|
+
const safeMsg = safeDiagnosticMessage(err, "remediation error");
|
|
355
404
|
result.warnings.push(`remediation-error for ${ticketKey}: ${safeMsg}`);
|
|
356
405
|
}
|
|
357
406
|
}
|