@bridge_gpt/mcp-server 0.2.67 → 0.2.68
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 +178 -15
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1374 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +18 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +964 -768
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +544 -37
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +29 -5
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process";
|
|
|
12
12
|
import { defaultResolveBinPath } from "../start-tickets-conductor.js";
|
|
13
13
|
import { CORRECTION_INTENT_CAPABILITY } from "./correction-dispatch.js";
|
|
14
14
|
import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
15
|
-
import { FOCUSED_REBASE_CAPABILITY, SCOPE_WAIT_CAPABILITY } from "./types.js";
|
|
15
|
+
import { FOCUSED_REBASE_CAPABILITY, GATE_QUALIFICATION_CAPABILITY, SCOPE_WAIT_CAPABILITY, } from "./types.js";
|
|
16
16
|
/**
|
|
17
17
|
* Environment override for the deny probe's headless timeout (BAPI-722).
|
|
18
18
|
*
|
|
@@ -68,7 +68,7 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
|
|
|
68
68
|
* hang, a control refusal, and a degraded guess all report `layer: "none"` for the
|
|
69
69
|
* posture they happened under, and none of them is cached.
|
|
70
70
|
*/
|
|
71
|
-
function
|
|
71
|
+
function isVerifiedDenyResult(result) {
|
|
72
72
|
if (result.enforced !== true || result.layer === "none")
|
|
73
73
|
return false;
|
|
74
74
|
const postures = result.postures;
|
|
@@ -76,17 +76,38 @@ function isCacheableDenyResult(result) {
|
|
|
76
76
|
return true;
|
|
77
77
|
return postures.every((p) => p.enforced === true && p.layer !== "none");
|
|
78
78
|
}
|
|
79
|
+
function isCacheableDenyResult(result) {
|
|
80
|
+
return result.degraded !== true && isVerifiedDenyResult(result);
|
|
81
|
+
}
|
|
79
82
|
/** Create a fresh, empty executor-scoped deny-probe cache. */
|
|
80
83
|
export function createDenyProbeCache() {
|
|
81
84
|
let cached;
|
|
85
|
+
let failedProbe = false;
|
|
86
|
+
let retryAt = 0;
|
|
87
|
+
let failures = 0;
|
|
88
|
+
const schedule = (nowMs) => {
|
|
89
|
+
failures += 1;
|
|
90
|
+
retryAt = nowMs + Math.min(30, 2 ** Math.min(failures - 1, 5)) * 60_000;
|
|
91
|
+
};
|
|
82
92
|
return {
|
|
83
|
-
get: () => cached,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
get: (nowMs = Date.now()) => cached && (isCacheableDenyResult(cached) || nowMs < retryAt) ? cached : undefined,
|
|
94
|
+
failedProbeDue: (nowMs) => failedProbe && nowMs < retryAt,
|
|
95
|
+
recordFailedProbe: (nowMs) => { failedProbe = true; schedule(nowMs); },
|
|
96
|
+
set: (result, nowMs = Date.now()) => {
|
|
97
|
+
failedProbe = false;
|
|
98
|
+
cached = result;
|
|
99
|
+
if (isCacheableDenyResult(result)) {
|
|
100
|
+
failures = 0;
|
|
101
|
+
retryAt = 0;
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
schedule(nowMs);
|
|
87
105
|
},
|
|
88
106
|
reset: () => {
|
|
89
107
|
cached = undefined;
|
|
108
|
+
failedProbe = false;
|
|
109
|
+
failures = 0;
|
|
110
|
+
retryAt = 0;
|
|
90
111
|
},
|
|
91
112
|
};
|
|
92
113
|
}
|
|
@@ -412,7 +433,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
412
433
|
let denyEnforced = false;
|
|
413
434
|
try {
|
|
414
435
|
const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
|
|
415
|
-
|
|
436
|
+
if (cache?.failedProbeDue?.(deps.now?.() ?? Date.now())) {
|
|
437
|
+
fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
|
|
438
|
+
throw new Error("cached deny probe failure");
|
|
439
|
+
}
|
|
440
|
+
const cachedDeny = cache?.get(deps.now?.() ?? Date.now());
|
|
416
441
|
let deny;
|
|
417
442
|
if (cachedDeny !== undefined) {
|
|
418
443
|
deny = cachedDeny;
|
|
@@ -421,7 +446,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
421
446
|
// An explicitly injected probe still wins, so every existing test seam
|
|
422
447
|
// keeps working unchanged across the extraction.
|
|
423
448
|
deny = await seams.runDenyPreflight({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
|
|
424
|
-
seams.denyProbeCache?.set(deny);
|
|
449
|
+
seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
|
|
425
450
|
}
|
|
426
451
|
else {
|
|
427
452
|
// Otherwise the ADAPTER's declared deny hook probes. The verdict is mapped
|
|
@@ -452,10 +477,10 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
452
477
|
};
|
|
453
478
|
// `set` itself refuses anything that is not a genuine success, so a failure
|
|
454
479
|
// cannot become sticky even if this call site were later moved.
|
|
455
|
-
seams.denyProbeCache?.set(deny);
|
|
480
|
+
seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
|
|
456
481
|
}
|
|
457
|
-
denyEnforced = deny
|
|
458
|
-
if (!
|
|
482
|
+
denyEnforced = isVerifiedDenyResult(deny);
|
|
483
|
+
if (!denyEnforced) {
|
|
459
484
|
fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
|
|
460
485
|
}
|
|
461
486
|
else if (deny.layer === "none") {
|
|
@@ -465,7 +490,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
465
490
|
}
|
|
466
491
|
}
|
|
467
492
|
catch {
|
|
468
|
-
fatalFindings.
|
|
493
|
+
if (!fatalFindings.includes("deny-layer enforcement probe failed; refusing to claim")) {
|
|
494
|
+
fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
|
|
495
|
+
if (!seams.bypassDenyProbeCache)
|
|
496
|
+
seams.denyProbeCache?.recordFailedProbe?.(deps.now?.() ?? Date.now());
|
|
497
|
+
}
|
|
469
498
|
}
|
|
470
499
|
// --- Advisory MCP-shadowing inspection (BAPI-727 → BAPI-790 → BAPI-781) --
|
|
471
500
|
// This was a refusal under BAPI-727, because a `bridge-api` entry in the
|
|
@@ -584,6 +613,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
584
613
|
* once ordinary preflight permits claiming — independently of completion-evidence
|
|
585
614
|
* qualification. The completion tokens keep their exact conditional behavior and
|
|
586
615
|
* order; the focused token is appended after them.
|
|
616
|
+
*
|
|
617
|
+
* BAPI-1391 adds two things. `gate_qualification.v1` is appended to
|
|
618
|
+
* `capabilities` ONLY on a repository-wide executor (see the comment at the
|
|
619
|
+
* token). And `job_types` is emitted, by conditional spread under the same
|
|
620
|
+
* "truly absent" rule, ONLY when `options.jobTypes` is set and non-empty; with
|
|
621
|
+
* it unset every manifest keeps its exact pre-BAPI-1391 shape apart from that
|
|
622
|
+
* one capability token on repository-wide executors.
|
|
587
623
|
*/
|
|
588
624
|
export function buildClaimManifest(report, options, freeSlots) {
|
|
589
625
|
return {
|
|
@@ -616,6 +652,19 @@ export function buildClaimManifest(report, options, freeSlots) {
|
|
|
616
652
|
SCOPE_WAIT_CAPABILITY,
|
|
617
653
|
...(report.correctionIntentQualified === true ? [CORRECTION_INTENT_CAPABILITY] : []),
|
|
618
654
|
...(report.correctionIntentQualified === true ? ["ci_evidence.v1"] : []),
|
|
655
|
+
// BAPI-1391: repository-wide executors only. A `gate_qualification` row is
|
|
656
|
+
// run-less (no `epic_run_id`), and run-less rows pass the server's
|
|
657
|
+
// run-scope filter, so a run-scoped executor advertising this token could
|
|
658
|
+
// pick up repository-level qualification work it was never dedicated to.
|
|
659
|
+
// Gating the token on `repoWide` keeps that work on the executor whose
|
|
660
|
+
// operator chose the whole repository.
|
|
661
|
+
...(options.repoWide === true ? [GATE_QUALIFICATION_CAPABILITY] : []),
|
|
619
662
|
],
|
|
663
|
+
// BAPI-1391: an optional job-type filter, present only when configured. An
|
|
664
|
+
// older server rejects the key with a 422, so it must never appear by
|
|
665
|
+
// default.
|
|
666
|
+
...(options.jobTypes !== undefined && options.jobTypes.length > 0
|
|
667
|
+
? { job_types: [...options.jobTypes] }
|
|
668
|
+
: {}),
|
|
620
669
|
};
|
|
621
670
|
}
|
|
@@ -266,6 +266,15 @@ export function buildCleanupVerdict(verdict, survivors, reasons) {
|
|
|
266
266
|
export function unconfirmedCleanup(reason) {
|
|
267
267
|
return buildCleanupVerdict("unconfirmed", 0, [reason]);
|
|
268
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The verdict when no sweep was attempted because ownership could not be
|
|
271
|
+
* proven (invalid token, malformed or unreadable ownership record). The extra
|
|
272
|
+
* `not_run` reason keeps this shape distinct from a completed sweep that merely
|
|
273
|
+
* could not verify ownership, so it is never treated as continuable.
|
|
274
|
+
*/
|
|
275
|
+
function noSweepCleanup() {
|
|
276
|
+
return buildCleanupVerdict("unconfirmed", 0, ["ownership_unverifiable", "not_run"]);
|
|
277
|
+
}
|
|
269
278
|
/** Hard cap on distinct processes tracked by one sweep or one descendant tracker. */
|
|
270
279
|
const MAX_TRACKED_PROCESSES = 1024;
|
|
271
280
|
/** Confirmation enumerations after SIGKILL (a killed process can take a moment to leave). */
|
|
@@ -302,8 +311,14 @@ export function createDescendantTracker(options) {
|
|
|
302
311
|
else
|
|
303
312
|
childrenOf.set(proc.ppid, [proc]);
|
|
304
313
|
}
|
|
305
|
-
//
|
|
306
|
-
// still
|
|
314
|
+
// Prune first: a member survives only while its exact (pid, startedAt)
|
|
315
|
+
// is still in this successful listing. Exited descendants must not
|
|
316
|
+
// exhaust the cap, and a reused pid loses its old identity.
|
|
317
|
+
for (const [key, member] of members) {
|
|
318
|
+
const live = byPid.get(member.pid);
|
|
319
|
+
if (!live || live.startedAt !== member.startedAt)
|
|
320
|
+
members.delete(key);
|
|
321
|
+
}
|
|
307
322
|
const frontier = [];
|
|
308
323
|
const rootNow = byPid.get(rootPid);
|
|
309
324
|
if (rootNow) {
|
|
@@ -312,12 +327,11 @@ export function createDescendantTracker(options) {
|
|
|
312
327
|
if (rootNow.startedAt === rootStartedAt)
|
|
313
328
|
frontier.push(rootPid);
|
|
314
329
|
}
|
|
315
|
-
for (const member of members.values())
|
|
316
|
-
|
|
317
|
-
if (live && live.startedAt === member.startedAt)
|
|
318
|
-
frontier.push(member.pid);
|
|
319
|
-
}
|
|
330
|
+
for (const member of members.values())
|
|
331
|
+
frontier.push(member.pid);
|
|
320
332
|
const visited = new Set(frontier);
|
|
333
|
+
let liveCount = members.size;
|
|
334
|
+
let capHit = false;
|
|
321
335
|
while (frontier.length > 0) {
|
|
322
336
|
const parent = frontier.pop();
|
|
323
337
|
for (const child of childrenOf.get(parent) ?? []) {
|
|
@@ -326,8 +340,9 @@ export function createDescendantTracker(options) {
|
|
|
326
340
|
visited.add(child.pid);
|
|
327
341
|
const key = keyOf(child);
|
|
328
342
|
if (!members.has(key)) {
|
|
343
|
+
liveCount += 1;
|
|
329
344
|
if (members.size >= MAX_TRACKED_PROCESSES) {
|
|
330
|
-
|
|
345
|
+
capHit = true;
|
|
331
346
|
continue;
|
|
332
347
|
}
|
|
333
348
|
members.set(key, { pid: child.pid, startedAt: child.startedAt });
|
|
@@ -335,6 +350,15 @@ export function createDescendantTracker(options) {
|
|
|
335
350
|
frontier.push(child.pid);
|
|
336
351
|
}
|
|
337
352
|
}
|
|
353
|
+
if (capHit && !overflow) {
|
|
354
|
+
overflow = true;
|
|
355
|
+
try {
|
|
356
|
+
options.onOverflow?.(liveCount);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
/* a diagnostic failure never changes tracking or the verdict */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
338
362
|
}
|
|
339
363
|
catch {
|
|
340
364
|
/* sampling is best-effort; the sweep's own verdict stays authoritative */
|
|
@@ -399,7 +423,7 @@ export async function runOwnedProcessSweep(options) {
|
|
|
399
423
|
const maxRounds = Math.max(quietRounds, options.maxRounds ?? 200);
|
|
400
424
|
const roundDelayMs = Math.max(0, options.roundDelayMs ?? 15);
|
|
401
425
|
if (!isOwnershipToken(token))
|
|
402
|
-
return
|
|
426
|
+
return noSweepCleanup();
|
|
403
427
|
const reasons = new Set();
|
|
404
428
|
if (options.trackerOverflowed)
|
|
405
429
|
reasons.add("ownership_unverifiable");
|
|
@@ -700,8 +724,8 @@ async function ownerMayBeAlive(inspector, pid) {
|
|
|
700
724
|
* interrupted before its own sweep finished. The record holds the exact token,
|
|
701
725
|
* so ownership is verified and the full sweep runs. Only the job id and the
|
|
702
726
|
* verdict are logged — never the token. A malformed record cannot prove
|
|
703
|
-
* ownership: it is reported `unconfirmed
|
|
704
|
-
* without signalling anything. Never throws.
|
|
727
|
+
* ownership: it is reported `unconfirmed` with `ownership_unverifiable` and
|
|
728
|
+
* `not_run` (no sweep ran) and removed without signalling anything. Never throws.
|
|
705
729
|
*/
|
|
706
730
|
export async function recoverInterruptedOwnership(options) {
|
|
707
731
|
const { stateDir, inspector, deps, fsDeps, deadlineMs } = options;
|
|
@@ -712,7 +736,7 @@ export async function recoverInterruptedOwnership(options) {
|
|
|
712
736
|
let verdict;
|
|
713
737
|
try {
|
|
714
738
|
if (record === null) {
|
|
715
|
-
verdict =
|
|
739
|
+
verdict = noSweepCleanup();
|
|
716
740
|
}
|
|
717
741
|
else if (options.hostDigest !== undefined && record.hostDigest !== options.hostDigest) {
|
|
718
742
|
continue;
|
|
@@ -11,17 +11,19 @@ export const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
|
11
11
|
*/
|
|
12
12
|
export function createProcessTerminationController(proc, deps, termGraceMs = DEFAULT_TERM_GRACE_MS) {
|
|
13
13
|
let requested = false;
|
|
14
|
+
let cause = null;
|
|
14
15
|
let escalated = false;
|
|
15
16
|
let disposed = false;
|
|
16
17
|
let graceTimer;
|
|
17
18
|
const killListeners = [];
|
|
18
19
|
return {
|
|
19
|
-
requestTermination() {
|
|
20
|
+
requestTermination(endCause = "executor_abort") {
|
|
20
21
|
// A disposed controller belongs to a settled child: signalling it would
|
|
21
22
|
// either hit nothing or, worse, a recycled pid.
|
|
22
23
|
if (requested || disposed)
|
|
23
24
|
return;
|
|
24
25
|
requested = true;
|
|
26
|
+
cause = endCause;
|
|
25
27
|
proc.kill("SIGTERM");
|
|
26
28
|
graceTimer = deps.setTimer(() => {
|
|
27
29
|
graceTimer = undefined;
|
|
@@ -42,6 +44,9 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
|
|
|
42
44
|
isTerminationRequested() {
|
|
43
45
|
return requested;
|
|
44
46
|
},
|
|
47
|
+
endCause() {
|
|
48
|
+
return cause;
|
|
49
|
+
},
|
|
45
50
|
dispose() {
|
|
46
51
|
if (disposed)
|
|
47
52
|
return;
|
|
@@ -125,7 +130,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
125
130
|
// shutdown, timeout, and MCP containment converge on a single `SIGTERM` and a
|
|
126
131
|
// single grace timer rather than racing three of each.
|
|
127
132
|
const controller = options.terminationController ?? createProcessTerminationController(proc, deps, termGraceMs);
|
|
128
|
-
const terminate = () => controller.requestTermination();
|
|
133
|
+
const terminate = (cause) => controller.requestTermination(cause);
|
|
129
134
|
// BAPI-1301 (D4a): the authoritative consumer, when supplied. `drainClosed`
|
|
130
135
|
// flips once the bounded post-exit drain gives up, so a chunk arriving after
|
|
131
136
|
// the verdict was taken can never mutate the ledger behind the caller's back.
|
|
@@ -160,18 +165,16 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
160
165
|
stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
|
|
161
166
|
if (options.onStdoutTerminationCheck?.(chunk) === true && !terminationRequested) {
|
|
162
167
|
terminationRequested = true;
|
|
163
|
-
terminate();
|
|
168
|
+
terminate("executor_abort");
|
|
164
169
|
}
|
|
165
170
|
});
|
|
166
171
|
const pumpStderr = pump(proc.stderr, (chunk) => {
|
|
167
172
|
stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
|
|
168
173
|
});
|
|
169
|
-
let timedOut = false;
|
|
170
174
|
const timeoutTimer = deps.setTimer(() => {
|
|
171
|
-
timedOut = true;
|
|
172
175
|
// Same escalation the assertion uses; the guard inside makes a timeout that
|
|
173
176
|
// races an already-requested termination a no-op rather than a double signal.
|
|
174
|
-
terminate();
|
|
177
|
+
terminate("job_timeout");
|
|
175
178
|
}, timeoutSeconds * 1000);
|
|
176
179
|
// BAPI-1301 (D4a): bounded waits, only when a drain deadline was supplied.
|
|
177
180
|
// Every timer created here is cleared before returning.
|
|
@@ -226,9 +229,13 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
226
229
|
deps.clearTimer(handle);
|
|
227
230
|
}
|
|
228
231
|
let classification;
|
|
229
|
-
|
|
232
|
+
const endCause = controller.endCause?.() ?? null;
|
|
233
|
+
if (endCause === "job_timeout") {
|
|
230
234
|
classification = "timeout";
|
|
231
235
|
}
|
|
236
|
+
else if (endCause !== null) {
|
|
237
|
+
classification = "killed";
|
|
238
|
+
}
|
|
232
239
|
else if (exitCode === 0) {
|
|
233
240
|
classification = "clean_exit";
|
|
234
241
|
}
|
|
@@ -241,6 +248,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
241
248
|
signal,
|
|
242
249
|
stdoutExcerpt,
|
|
243
250
|
stderrExcerpt,
|
|
251
|
+
endCause: endCause ?? "worker_exit",
|
|
244
252
|
...(terminationRequested ? { terminationRequested: true } : {}),
|
|
245
253
|
// BAPI-1301 fields appear only for callers that opted in, so every legacy
|
|
246
254
|
// result keeps its exact shape.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderWorkerVerificationGuidance } from "./worker-verification-guidance.js";
|
|
1
2
|
/**
|
|
2
3
|
* Executor-side structured prompt-spec renderer (BAPI-550, TDD §6/§7).
|
|
3
4
|
*
|
|
@@ -18,6 +19,35 @@
|
|
|
18
19
|
* or reasons.
|
|
19
20
|
*/
|
|
20
21
|
import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
|
|
22
|
+
import { EPIC_WORKAROUNDS_DIR, epicWorkaroundRecordPath } from "../epic-workarounds-path.js";
|
|
23
|
+
/**
|
|
24
|
+
* BAPI-1399 — the workaround contract for a PR-producing Epic Implementer worker.
|
|
25
|
+
*
|
|
26
|
+
* A requirement the worker cannot meet unattended (a missing credential, an
|
|
27
|
+
* external setting, or access) is either MINOR — the ticket's essential
|
|
28
|
+
* behavior still works without it — or CORE. A minor gap gets a guarded
|
|
29
|
+
* workaround and a committed record at `.bridge/epic-workarounds/<TICKET>.md`;
|
|
30
|
+
* while that folder exists on the epic head the integration pull request stays
|
|
31
|
+
* in draft. A core gap is never worked around: the worker leaves the required
|
|
32
|
+
* work unfinished, which parks the ticket as `required_work_incomplete` through
|
|
33
|
+
* the unchanged completion contract.
|
|
34
|
+
*
|
|
35
|
+
* The worker decides from the ticket and the code alone; no supervisor scan
|
|
36
|
+
* result is injected here. Kept SINGLE-LINE and free of `;`, quotes, and
|
|
37
|
+
* apostrophes, like `buildPrBaseContractLaunchInstruction`, because it travels
|
|
38
|
+
* in the same launch prompt.
|
|
39
|
+
*/
|
|
40
|
+
export function buildEpicWorkaroundWorkerInstruction(ticketKey) {
|
|
41
|
+
const record = (ticketKey ? epicWorkaroundRecordPath(ticketKey) : null) ?? `${EPIC_WORKAROUNDS_DIR}<TICKET>.md`;
|
|
42
|
+
return ("Workaround contract: if a requirement of this ticket cannot be met unattended, such as a missing " +
|
|
43
|
+
"credential, external setting, or access, decide whether it is minor or core. It is minor only when " +
|
|
44
|
+
"the essential behavior of the ticket still works without it. For a minor gap, deliver a guarded " +
|
|
45
|
+
"workaround: guard exactly the deferred behavior (for example, check for a missing setting and skip " +
|
|
46
|
+
"only that feature), keep the core path working, and write " + record + " naming the requirement, " +
|
|
47
|
+
"the guarded substitute you delivered, and the step a human must still take. Commit that record on " +
|
|
48
|
+
"this ticket branch with git add -f " + record + ". For a core gap, never write a workaround or a " +
|
|
49
|
+
"record: leave the required work unfinished so the run parks this ticket for a human.");
|
|
50
|
+
}
|
|
21
51
|
/** Stable named-error kind used for every prompt-spec contract failure. */
|
|
22
52
|
const PROMPT_CONTRACT_ERROR = "ContractError.Prompt";
|
|
23
53
|
/** The prompt-spec version this renderer understands. */
|
|
@@ -647,5 +677,7 @@ export async function renderPromptSpecPrompt(job, context) {
|
|
|
647
677
|
const values = await buildPromptSpecPlaceholderValues(job, spec, context);
|
|
648
678
|
const systemPrompt = buildPromptSpecSystemPrompt(spec, values);
|
|
649
679
|
const userPrompt = buildPromptSpecUserPrompt(spec, values);
|
|
650
|
-
return `${systemPrompt}\n\n${userPrompt}
|
|
680
|
+
return `${systemPrompt}\n\n${userPrompt}` +
|
|
681
|
+
(!context.deferComparisonGuidance && ["implement", "resume", "ci_fix"].includes(job.job_type)
|
|
682
|
+
? "\n\n" + renderWorkerVerificationGuidance(context.comparisonSha) : "");
|
|
651
683
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Atomic resolver refresh. Caller retains the exclusive worktree lock. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { parseRecentlyFailedTests, RECENTLY_FAILED_TESTS_PATH } from "./likely-tests.js";
|
|
7
|
+
export function validatedRecentFailures(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return { version: 1, tests: [], truncated: false };
|
|
10
|
+
const parsed = parseRecentlyFailedTests(value);
|
|
11
|
+
if (!parsed.available || parsed.skipped || JSON.stringify(value).length > 16 * 1024 ||
|
|
12
|
+
value.tests.length > 20)
|
|
13
|
+
throw new Error("recent_failure_schema_invalid");
|
|
14
|
+
return parsed.value;
|
|
15
|
+
}
|
|
16
|
+
export async function writeRecentlyFailedTestsFile(worktree, value) {
|
|
17
|
+
const selection = validatedRecentFailures(value);
|
|
18
|
+
const root = await realpath(worktree);
|
|
19
|
+
const directory = path.join(root, ".conductor");
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(directory);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== "EEXIST")
|
|
25
|
+
throw new Error("recent_failure_directory_unavailable");
|
|
26
|
+
}
|
|
27
|
+
const directoryStat = await lstat(directory);
|
|
28
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
29
|
+
throw new Error("recent_failure_directory_unsafe");
|
|
30
|
+
const destination = path.join(root, RECENTLY_FAILED_TESTS_PATH);
|
|
31
|
+
const safeDestination = async () => {
|
|
32
|
+
if (await realpath(directory) !== directory || !(await lstat(directory)).isDirectory() ||
|
|
33
|
+
(await lstat(directory)).isSymbolicLink())
|
|
34
|
+
throw new Error("recent_failure_directory_changed");
|
|
35
|
+
try {
|
|
36
|
+
const stat = await lstat(destination);
|
|
37
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
38
|
+
throw new Error("recent_failure_destination_unsafe");
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT")
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await safeDestination();
|
|
46
|
+
const temporary = path.join(directory, `.recent-failures-${randomUUID()}.tmp`);
|
|
47
|
+
let created = false;
|
|
48
|
+
try {
|
|
49
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
50
|
+
created = true;
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(JSON.stringify(selection) + "\n", "utf8");
|
|
53
|
+
await handle.sync();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
if (await realpath(temporary) !== temporary || !(await lstat(temporary)).isFile())
|
|
59
|
+
throw new Error("recent_failure_temporary_unsafe");
|
|
60
|
+
await safeDestination();
|
|
61
|
+
const current = await lstat(directory);
|
|
62
|
+
if (current.ino !== directoryStat.ino || current.dev !== directoryStat.dev)
|
|
63
|
+
throw new Error("recent_failure_directory_changed");
|
|
64
|
+
await rename(temporary, destination);
|
|
65
|
+
created = false;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
if (created && await realpath(directory).catch(() => "") === directory)
|
|
69
|
+
await unlink(temporary).catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { guardedFastForwardPush, PUSH_AUTH_FAILURE_RE } from "./guarded-fast-forward-push.js";
|
|
1
2
|
/**
|
|
2
3
|
* The `reconcile_overlap` executor job (BAPI-1152).
|
|
3
4
|
*
|
|
@@ -71,7 +72,6 @@ const CONFLICT_REPORT_DIR = ".conductor/";
|
|
|
71
72
|
const CONDUCTOR_ARTIFACT_DIR = ".conductor/";
|
|
72
73
|
/** Command bounds. */
|
|
73
74
|
const GIT_TIMEOUT_MS = 120_000;
|
|
74
|
-
const PUSH_TIMEOUT_MS = 180_000;
|
|
75
75
|
const COMMAND_OUTPUT_TAIL_CHARS = 4_000;
|
|
76
76
|
/** Left in the job budget when the wrapper waits for provider checks. */
|
|
77
77
|
const CI_WAIT_MARGIN_MS = 90_000;
|
|
@@ -91,8 +91,6 @@ const WRAPPER_STAGE_EXCLUDES = [
|
|
|
91
91
|
":(exclude).claude/settings.local.json",
|
|
92
92
|
];
|
|
93
93
|
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
94
|
-
const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
|
|
95
|
-
const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
|
|
96
94
|
class PayloadError extends Error {
|
|
97
95
|
}
|
|
98
96
|
function asRecord(value, field) {
|
|
@@ -339,9 +337,13 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
339
337
|
const git = async (ctx, args, timeoutMs = GIT_TIMEOUT_MS, env) => {
|
|
340
338
|
if (ctx.signal?.aborted)
|
|
341
339
|
throw new ReconcileOverlapAborted("reconciliation phase aborted");
|
|
340
|
+
const remaining = "remainingMs" in ctx ? ctx.remainingMs() : timeoutMs;
|
|
341
|
+
if (remaining <= 0)
|
|
342
|
+
throw new ReconcileOverlapAborted("reconciliation deadline expired");
|
|
342
343
|
return seams.runCommand("git", args, {
|
|
343
344
|
cwd: ctx.worktreePath,
|
|
344
|
-
timeoutMs,
|
|
345
|
+
timeoutMs: Math.min(timeoutMs, remaining),
|
|
346
|
+
signal: ctx.signal,
|
|
345
347
|
...(env ? { env } : {}),
|
|
346
348
|
});
|
|
347
349
|
};
|
|
@@ -374,8 +376,12 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
374
376
|
result: envelope(extra),
|
|
375
377
|
...(lastCommitSha ? { lastCommitSha } : {}),
|
|
376
378
|
});
|
|
379
|
+
let preserveHead = false;
|
|
380
|
+
let cleanupHead = payload.childHead;
|
|
377
381
|
/** Reset to the recorded child head. Never throws. */
|
|
378
382
|
const resetToChildHead = async (ctx) => {
|
|
383
|
+
if (preserveHead)
|
|
384
|
+
return;
|
|
379
385
|
try {
|
|
380
386
|
const mergeHead = await seams.runCommand("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
381
387
|
cwd: ctx.worktreePath,
|
|
@@ -384,7 +390,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
384
390
|
if (mergeHead.exitCode === 0) {
|
|
385
391
|
await seams.runCommand("git", ["merge", "--abort"], { cwd: ctx.worktreePath, timeoutMs: GIT_TIMEOUT_MS });
|
|
386
392
|
}
|
|
387
|
-
await seams.runCommand("git", ["reset", "--hard",
|
|
393
|
+
await seams.runCommand("git", ["reset", "--hard", cleanupHead], {
|
|
388
394
|
cwd: ctx.worktreePath,
|
|
389
395
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
390
396
|
});
|
|
@@ -595,35 +601,33 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
595
601
|
}
|
|
596
602
|
return complete({ observed_epic_head: heads.epic });
|
|
597
603
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
"origin",
|
|
607
|
-
`HEAD:${destination}`,
|
|
608
|
-
], PUSH_TIMEOUT_MS, { ...seams.env, GIT_TERMINAL_PROMPT: "0" });
|
|
609
|
-
if (push.exitCode !== 0) {
|
|
610
|
-
const text = `${push.stdout}\n${push.stderr}`;
|
|
611
|
-
if (PUSH_AUTH_FAILURE_RE.test(text)) {
|
|
604
|
+
const push = await guardedFastForwardPush({
|
|
605
|
+
branch: payload.expectedBranch, expectedOriginSha: payload.childHead,
|
|
606
|
+
candidateSha: candidate, trustedExcludedShas: [payload.requiredEpicSha],
|
|
607
|
+
worktreePath: ctx.worktreePath, runCommand: seams.runCommand, env: seams.env,
|
|
608
|
+
remainingMs: ctx.remainingMs, signal: ctx.signal, mayMutate: () => !ctx.signal.aborted,
|
|
609
|
+
});
|
|
610
|
+
if (push.status !== "published") {
|
|
611
|
+
if (push.reason === "auth_failed") {
|
|
612
612
|
await resetToChildHead(ctx);
|
|
613
613
|
return fail(ReconcileOverlapPushUnauthorized, "pushed", "the executor's git credential helper refused the push");
|
|
614
614
|
}
|
|
615
|
-
if (
|
|
615
|
+
if (push.reason === "lease_rejected") {
|
|
616
616
|
const reread = await readRemoteHeads(ctx);
|
|
617
617
|
await resetToChildHead(ctx);
|
|
618
|
-
if (reread.ok && reread.child !== null && reread.child !== payload.childHead)
|
|
618
|
+
if (reread.ok && reread.child !== null && reread.child !== payload.childHead)
|
|
619
619
|
return complete({ observed_child_head: reread.child });
|
|
620
|
-
}
|
|
621
620
|
}
|
|
622
|
-
else {
|
|
621
|
+
else if (push.reason !== "timeout" && !ctx.signal.aborted) {
|
|
623
622
|
await resetToChildHead(ctx);
|
|
624
623
|
}
|
|
624
|
+
else {
|
|
625
|
+
preserveHead = true;
|
|
626
|
+
}
|
|
625
627
|
return fail(ReconcileOverlapPushRejected, "pushed", "the remote refused the guarded push");
|
|
626
628
|
}
|
|
629
|
+
preserveHead = true;
|
|
630
|
+
ctx.recordPublication?.(push);
|
|
627
631
|
// (3) Finalization runs ONCE, after the wrapper's push, against the pushed
|
|
628
632
|
// head: origin must carry it and the PR must be attached at it.
|
|
629
633
|
const finalize = seams.finalize ?? validateWorkerFinalization;
|
|
@@ -633,6 +637,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
633
637
|
worktreePath: ctx.worktreePath,
|
|
634
638
|
result: {},
|
|
635
639
|
runCommand: seams.runCommand,
|
|
640
|
+
callTimeoutMs: () => Math.min(GIT_TIMEOUT_MS, ctx.remainingMs()),
|
|
636
641
|
headSha: candidate,
|
|
637
642
|
expectedBaseBranch: ctx.effectiveBaseBranch,
|
|
638
643
|
originResolveAttempts: FINALIZATION_ATTEMPTS,
|
|
@@ -658,6 +663,16 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
658
663
|
if (ctx.branch !== payload.expectedBranch) {
|
|
659
664
|
return finish(fail(ReconcileOverlapBranchMismatch, "validated", "the prepared worktree is not on the job's expected branch"));
|
|
660
665
|
}
|
|
666
|
+
if (ctx.preparationPublication) {
|
|
667
|
+
preserveHead = true;
|
|
668
|
+
const heads = await readRemoteHeads(ctx);
|
|
669
|
+
if (!heads.ok)
|
|
670
|
+
return finish(heads.outcome);
|
|
671
|
+
if (heads.child === null)
|
|
672
|
+
return finish(fail(ReconcileOverlapPushRejected, "validated", "the child branch no longer exists on origin"));
|
|
673
|
+
cleanupHead = heads.child;
|
|
674
|
+
return finish(complete({ observed_child_head: heads.child }));
|
|
675
|
+
}
|
|
661
676
|
// Fetch the exact server-supplied inputs. The job never infers a head.
|
|
662
677
|
await git(ctx, ["fetch", "--no-tags", "origin", payload.epicBranch], GIT_TIMEOUT_MS, {
|
|
663
678
|
...seams.env,
|
|
@@ -831,6 +846,9 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
831
846
|
return pushFlow(ctx);
|
|
832
847
|
},
|
|
833
848
|
async abandon(ctx) {
|
|
849
|
+
// Wrapper-created merges are not retained worker progress until their
|
|
850
|
+
// verification/publication gate succeeds. Published or ambiguous pushes,
|
|
851
|
+
// including preparation publication, already set preserveHead above.
|
|
834
852
|
await resetToChildHead(ctx);
|
|
835
853
|
},
|
|
836
854
|
};
|
|
@@ -236,7 +236,7 @@ export function createSupervisedCommandLauncher(input) {
|
|
|
236
236
|
let cancelled = false;
|
|
237
237
|
const onAbort = () => {
|
|
238
238
|
cancelled = true;
|
|
239
|
-
controller.requestTermination();
|
|
239
|
+
controller.requestTermination("executor_abort");
|
|
240
240
|
};
|
|
241
241
|
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
242
242
|
try {
|