@bridge_gpt/mcp-server 0.2.50 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
15
15
|
* read-only viewer tabs wired around every spawned worker.
|
|
16
16
|
*/
|
|
17
|
-
import { rm } from "node:fs/promises";
|
|
17
|
+
import { rm, readdir, lstat, unlink } from "node:fs/promises";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
-
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, } from "./job-errors.js";
|
|
20
|
+
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
|
|
21
21
|
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
22
|
import { createObservationState } from "./observation.js";
|
|
23
23
|
import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
@@ -27,9 +27,13 @@ import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
|
27
27
|
import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
|
|
28
28
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
29
29
|
import { renderPromptSpecPrompt } from "./prompt-spec.js";
|
|
30
|
+
import { defaultResolveBinPath } from "../start-tickets-conductor.js";
|
|
31
|
+
/** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
|
|
32
|
+
const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
|
|
30
33
|
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
31
34
|
import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
|
|
32
35
|
import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
36
|
+
import { stripWorkerEnvFiles } from "./env-file-guard.js";
|
|
33
37
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
34
38
|
import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
35
39
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
@@ -392,7 +396,7 @@ async function checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, ad
|
|
|
392
396
|
* `controls.signal` (overall-timeout abort) is threaded into local merge deps
|
|
393
397
|
* without changing the local `gh` credential model.
|
|
394
398
|
*/
|
|
395
|
-
export async function defaultRunMergeForClaimed(job, deps,
|
|
399
|
+
export async function defaultRunMergeForClaimed(job, deps, options, controls) {
|
|
396
400
|
const accessResult = await buildConductorMergeAccessForExecutorJob({
|
|
397
401
|
env: deps.env,
|
|
398
402
|
cwd: deps.cwd,
|
|
@@ -419,6 +423,12 @@ export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
|
|
|
419
423
|
// Thread the overall-timeout abort signal into local merge deps WITHOUT
|
|
420
424
|
// changing the local `gh` credential model (the signal is not a credential).
|
|
421
425
|
localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
|
|
426
|
+
// BAPI-951: the executor's own configured base — resolveExecutorJobBaseBranch
|
|
427
|
+
// (called inside runExecutorMergeJob) resolves the CHILD PR's base from the
|
|
428
|
+
// job's persisted run base, falling back to this value for a legacy job.
|
|
429
|
+
repositoryBaseBranch: options.baseBranch,
|
|
430
|
+
epicIntegrationAdvisoryLog: deps.errorLog,
|
|
431
|
+
cwd: deps.cwd,
|
|
422
432
|
});
|
|
423
433
|
}
|
|
424
434
|
/** An in-memory owned process for the no-op `smoke` acceptance job. */
|
|
@@ -493,6 +503,18 @@ async function superviseProcess(params) {
|
|
|
493
503
|
isDoneRef.value = true;
|
|
494
504
|
const loopOutcome = await hb;
|
|
495
505
|
params.observation.setExitCode(result.exitCode);
|
|
506
|
+
if (loopOutcome.kind === "auth_fatal") {
|
|
507
|
+
// BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
|
|
508
|
+
// identifiers — never carried onto `result` itself, so the process's own
|
|
509
|
+
// classification/completion handling is unaffected and the job continues
|
|
510
|
+
// to drain normally.
|
|
511
|
+
params.control?.reportAuthFatal?.({
|
|
512
|
+
jobId: params.job.id,
|
|
513
|
+
repoName: params.job.repo_name,
|
|
514
|
+
category: "heartbeat",
|
|
515
|
+
httpStatus: loopOutcome.httpStatus,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
496
518
|
return loopOutcome.kind === "server_stop"
|
|
497
519
|
? { ...result, serverStopRequested: true }
|
|
498
520
|
: result;
|
|
@@ -509,7 +531,33 @@ function terminalToRunResult(terminal, successStatus) {
|
|
|
509
531
|
return { status: "abandoned", reason: "deadman" };
|
|
510
532
|
case "skipped":
|
|
511
533
|
return { status: "abandoned", reason: "already_abandoned" };
|
|
534
|
+
case "auth_fatal":
|
|
535
|
+
// BAPI-1021 (AC-5): a halted authentication/access condition, not an
|
|
536
|
+
// ordinary job failure or a lost/stale claim — `reportAuthFatal` (called
|
|
537
|
+
// by the wrapper below, before this mapping runs) is what actually
|
|
538
|
+
// notifies the runner.
|
|
539
|
+
return { status: "abandoned", reason: "auth_fatal" };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* BAPI-1021 (AC-5): send a terminal mutation via
|
|
544
|
+
* {@link sendTerminalMutationWithRetry}, and — on an `auth_fatal` outcome —
|
|
545
|
+
* notify the runner exactly once via `control.reportAuthFatal`, carrying ONLY
|
|
546
|
+
* the job ID, repository, operation category, and HTTP status. Every other
|
|
547
|
+
* outcome passes through completely unchanged.
|
|
548
|
+
*/
|
|
549
|
+
async function sendTerminalMutationWithAuthReport(params, job, control) {
|
|
550
|
+
const terminal = await sendTerminalMutationWithRetry(params);
|
|
551
|
+
if (terminal.outcome === "auth_fatal") {
|
|
552
|
+
const notice = {
|
|
553
|
+
jobId: job.id,
|
|
554
|
+
repoName: job.repo_name,
|
|
555
|
+
category: params.kind,
|
|
556
|
+
httpStatus: terminal.httpStatus,
|
|
557
|
+
};
|
|
558
|
+
control?.reportAuthFatal?.(notice);
|
|
512
559
|
}
|
|
560
|
+
return terminal;
|
|
513
561
|
}
|
|
514
562
|
/** Dispatch and run a single claimed job. */
|
|
515
563
|
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
@@ -543,10 +591,10 @@ control) {
|
|
|
543
591
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
544
592
|
// worker (TDD §5/§9).
|
|
545
593
|
if (job.job_type === "merge") {
|
|
546
|
-
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
594
|
+
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
547
595
|
}
|
|
548
596
|
if (job.job_type === "smoke") {
|
|
549
|
-
return runSmokeJob(job, httpClient, options, deps, ownership, observation);
|
|
597
|
+
return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
|
|
550
598
|
}
|
|
551
599
|
// `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
|
|
552
600
|
// `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
|
|
@@ -634,7 +682,7 @@ function createMergeFlowProcess(startMerge) {
|
|
|
634
682
|
* ignored. The local merge executor's outcome otherwise drives `/complete`
|
|
635
683
|
* (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
|
|
636
684
|
*/
|
|
637
|
-
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
685
|
+
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control) {
|
|
638
686
|
// Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
|
|
639
687
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
640
688
|
if (!timeout.ok) {
|
|
@@ -658,6 +706,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
658
706
|
proc: flow.proc,
|
|
659
707
|
timeoutSeconds,
|
|
660
708
|
collectTelemetry: async () => ({}),
|
|
709
|
+
control,
|
|
661
710
|
});
|
|
662
711
|
// Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
|
|
663
712
|
// mutation for a claim we no longer own, and never process a late outcome.
|
|
@@ -667,7 +716,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
667
716
|
// Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
|
|
668
717
|
// any later merge promise resolution (the abort has already been signalled).
|
|
669
718
|
if (procResult.classification === "timeout") {
|
|
670
|
-
const terminal = await
|
|
719
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
671
720
|
kind: "fail",
|
|
672
721
|
send: () => httpClient.fail(job, {
|
|
673
722
|
error_kind: MERGE_RETRYABLE,
|
|
@@ -679,13 +728,13 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
679
728
|
options,
|
|
680
729
|
ownership,
|
|
681
730
|
log: deps.log,
|
|
682
|
-
});
|
|
731
|
+
}, job, control);
|
|
683
732
|
return terminalToRunResult(terminal, "failed");
|
|
684
733
|
}
|
|
685
734
|
// A thrown error inside the merge flow maps to a bounded MergeFailed.
|
|
686
735
|
const flowError = flow.getError();
|
|
687
736
|
if (flowError !== undefined) {
|
|
688
|
-
const terminal = await
|
|
737
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
689
738
|
kind: "fail",
|
|
690
739
|
send: () => httpClient.fail(job, {
|
|
691
740
|
error_kind: MERGE_FAILED,
|
|
@@ -697,14 +746,14 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
697
746
|
options,
|
|
698
747
|
ownership,
|
|
699
748
|
log: deps.log,
|
|
700
|
-
});
|
|
749
|
+
}, job, control);
|
|
701
750
|
return terminalToRunResult(terminal, "failed");
|
|
702
751
|
}
|
|
703
752
|
const outcome = flow.getOutcome();
|
|
704
753
|
if (outcome === undefined) {
|
|
705
754
|
// Clean process result with no stored outcome — an internal invariant break;
|
|
706
755
|
// fail loud rather than silently reporting success.
|
|
707
|
-
const terminal = await
|
|
756
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
708
757
|
kind: "fail",
|
|
709
758
|
send: () => httpClient.fail(job, {
|
|
710
759
|
error_kind: MERGE_FAILED,
|
|
@@ -716,7 +765,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
716
765
|
options,
|
|
717
766
|
ownership,
|
|
718
767
|
log: deps.log,
|
|
719
|
-
});
|
|
768
|
+
}, job, control);
|
|
720
769
|
return terminalToRunResult(terminal, "failed");
|
|
721
770
|
}
|
|
722
771
|
if (outcome.ok) {
|
|
@@ -727,24 +776,24 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
727
776
|
result: outcome.result,
|
|
728
777
|
telemetry: observation.snapshot(),
|
|
729
778
|
};
|
|
730
|
-
const terminal = await
|
|
779
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
731
780
|
kind: "complete",
|
|
732
781
|
send: () => httpClient.complete(job, completion),
|
|
733
782
|
deps,
|
|
734
783
|
options,
|
|
735
784
|
ownership,
|
|
736
785
|
log: deps.log,
|
|
737
|
-
});
|
|
786
|
+
}, job, control);
|
|
738
787
|
return terminalToRunResult(terminal, "completed");
|
|
739
788
|
}
|
|
740
|
-
const terminal = await
|
|
789
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
741
790
|
kind: "fail",
|
|
742
791
|
send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
|
|
743
792
|
deps,
|
|
744
793
|
options,
|
|
745
794
|
ownership,
|
|
746
795
|
log: deps.log,
|
|
747
|
-
});
|
|
796
|
+
}, job, control);
|
|
748
797
|
return terminalToRunResult(terminal, "failed");
|
|
749
798
|
}
|
|
750
799
|
/**
|
|
@@ -761,7 +810,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
761
810
|
* job to give it something to register MCP into would be a materially larger,
|
|
762
811
|
* unratified change to that architectural boundary, not a wiring task.
|
|
763
812
|
*/
|
|
764
|
-
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
813
|
+
async function runSmokeJob(job, httpClient, options, deps, ownership, observation, control) {
|
|
765
814
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
766
815
|
if (!timeout.ok) {
|
|
767
816
|
// Consistent with runSpawnJob: a timeout-contract failure fails the job
|
|
@@ -790,6 +839,7 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
790
839
|
proc,
|
|
791
840
|
timeoutSeconds,
|
|
792
841
|
collectTelemetry: async () => ({}),
|
|
842
|
+
control,
|
|
793
843
|
});
|
|
794
844
|
if (ownership.abandoned) {
|
|
795
845
|
return { status: "abandoned", reason: ownership.abandonReason };
|
|
@@ -807,14 +857,14 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
807
857
|
result,
|
|
808
858
|
telemetry: observation.snapshot(),
|
|
809
859
|
};
|
|
810
|
-
const terminal = await
|
|
860
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
811
861
|
kind: "complete",
|
|
812
862
|
send: () => httpClient.complete(job, completion),
|
|
813
863
|
deps,
|
|
814
864
|
options,
|
|
815
865
|
ownership,
|
|
816
866
|
log: deps.log,
|
|
817
|
-
});
|
|
867
|
+
}, job, control);
|
|
818
868
|
return terminalToRunResult(terminal, "completed");
|
|
819
869
|
}
|
|
820
870
|
/**
|
|
@@ -1278,6 +1328,14 @@ async function runPreparedSpawn(params) {
|
|
|
1278
1328
|
worktreePath,
|
|
1279
1329
|
baseBranch: effectiveBaseBranch,
|
|
1280
1330
|
homedir: deps.homedir(),
|
|
1331
|
+
// BAPI-1020 — the prepared worktree's own branch and the packaged guard bin.
|
|
1332
|
+
// `branch` comes from `prepareSpawn`'s `wt.branch`, i.e. the worktree the
|
|
1333
|
+
// executor actually created, never from job payload text: the guard decides
|
|
1334
|
+
// "is this push destination mine?" by comparing against it, so a value the
|
|
1335
|
+
// payload could influence would let a job nominate its own safe branch.
|
|
1336
|
+
workerBranch: branch,
|
|
1337
|
+
workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
|
|
1338
|
+
(() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
|
|
1281
1339
|
});
|
|
1282
1340
|
// Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
|
|
1283
1341
|
// hygiene step can degrade without failing the deny layer — present-but-
|
|
@@ -1348,6 +1406,13 @@ async function runPreparedSpawn(params) {
|
|
|
1348
1406
|
writeFile: deps.writeFile,
|
|
1349
1407
|
appendFile: deps.appendFile,
|
|
1350
1408
|
platform: deps.platform,
|
|
1409
|
+
// BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
|
|
1410
|
+
// already, so this is source-compatible with every existing deps
|
|
1411
|
+
// builder — archival simply does not run when either is absent.
|
|
1412
|
+
rename: deps.rename,
|
|
1413
|
+
stat: deps.stat,
|
|
1414
|
+
now: deps.now,
|
|
1415
|
+
errorLog: deps.errorLog,
|
|
1351
1416
|
});
|
|
1352
1417
|
}
|
|
1353
1418
|
catch {
|
|
@@ -1470,6 +1535,10 @@ async function runPreparedSpawn(params) {
|
|
|
1470
1535
|
mcpConfigPath: mcpContract.configPath,
|
|
1471
1536
|
effectiveBaseBranch,
|
|
1472
1537
|
indexScope,
|
|
1538
|
+
// BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
|
|
1539
|
+
// is the deterministic guard's only source of branch identity. Same
|
|
1540
|
+
// provenance as the deny-provisioning value above: the prepared worktree.
|
|
1541
|
+
workerBranch: branch,
|
|
1473
1542
|
parentEnv: deps.env,
|
|
1474
1543
|
});
|
|
1475
1544
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -1508,6 +1577,44 @@ async function runPreparedSpawn(params) {
|
|
|
1508
1577
|
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1509
1578
|
}
|
|
1510
1579
|
const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
|
|
1580
|
+
// --- Worker env-file strip (FAIL-CLOSED, BAPI-1019/A.4) ---------------
|
|
1581
|
+
// THE LAST thing that touches the worktree before a worker exists. Placed
|
|
1582
|
+
// after the deny layer, after MCP provisioning and its verification, and after
|
|
1583
|
+
// spawn-shape/observer construction precisely so nothing that runs later can
|
|
1584
|
+
// re-introduce a file the guard just confirmed gone. Everything between this
|
|
1585
|
+
// call and `deps.spawnProcess` below is pure in-memory work.
|
|
1586
|
+
//
|
|
1587
|
+
// Fail-CLOSED, following `StaleArtifactCleanupFailed` rather than the
|
|
1588
|
+
// fail-OPEN deny-layer posture two hundred lines up. The deny layer shrinks a
|
|
1589
|
+
// safety margin when it degrades; this gate is the margin. Architecture Miss 28
|
|
1590
|
+
// was a worker that reached the operator's real database through a copied
|
|
1591
|
+
// `.env` and truncated it — so "could not confirm the worktree is clean" must
|
|
1592
|
+
// refuse, never proceed and hope.
|
|
1593
|
+
const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
|
|
1594
|
+
const stripped = await stripEnvFiles(worktreePath, {
|
|
1595
|
+
readdir: (dirPath) => readdir(dirPath),
|
|
1596
|
+
// `lstat`/`unlink` only — the guard must never resolve a symlink, because an
|
|
1597
|
+
// entry pointing at the main checkout would resolve to the operator's real
|
|
1598
|
+
// `.env` and unlinking THAT would delete it. The narrow bag is the
|
|
1599
|
+
// enforcement: no `stat`, `readlink`, or `readFile` is reachable from here.
|
|
1600
|
+
lstat: (target) => lstat(target),
|
|
1601
|
+
unlink: (target) => unlink(target),
|
|
1602
|
+
platform: deps.platform,
|
|
1603
|
+
});
|
|
1604
|
+
if (!stripped.ok) {
|
|
1605
|
+
await closeWorkerLog();
|
|
1606
|
+
await finalizeRegistry();
|
|
1607
|
+
// Exactly ONE bounded stderr line: the job id and the closed guard category.
|
|
1608
|
+
// No worktree path (it carries a username), no removed filename, no errno, no
|
|
1609
|
+
// exception text.
|
|
1610
|
+
deps.errorLog(`[executor] job ${job.id}: refusing to spawn, worker env files unconfirmed: ${stripped.category}`);
|
|
1611
|
+
await httpClient.fail(job, {
|
|
1612
|
+
error_kind: WorkerEnvFilePresent,
|
|
1613
|
+
error_message: WORKER_ENV_FILE_PRESENT_MESSAGE,
|
|
1614
|
+
classification: "crashed",
|
|
1615
|
+
});
|
|
1616
|
+
return { status: "failed", reason: "worker_env_file_present" };
|
|
1617
|
+
}
|
|
1511
1618
|
let proc;
|
|
1512
1619
|
try {
|
|
1513
1620
|
proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
|
|
@@ -1577,6 +1684,7 @@ async function runPreparedSpawn(params) {
|
|
|
1577
1684
|
// and an MCP containment refusal all converge on one `SIGTERM` and one grace
|
|
1578
1685
|
// timer for this child (BAPI-828).
|
|
1579
1686
|
terminationController,
|
|
1687
|
+
control,
|
|
1580
1688
|
});
|
|
1581
1689
|
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
1582
1690
|
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
@@ -1736,14 +1844,14 @@ async function runPreparedSpawn(params) {
|
|
|
1736
1844
|
classification: "crashed",
|
|
1737
1845
|
};
|
|
1738
1846
|
await finalizeRegistry();
|
|
1739
|
-
const terminal = await
|
|
1847
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1740
1848
|
kind: "fail",
|
|
1741
1849
|
send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
|
|
1742
1850
|
deps,
|
|
1743
1851
|
options,
|
|
1744
1852
|
ownership,
|
|
1745
1853
|
log: deps.log,
|
|
1746
|
-
});
|
|
1854
|
+
}, job, control);
|
|
1747
1855
|
return terminalToRunResult(terminal, "failed");
|
|
1748
1856
|
}
|
|
1749
1857
|
// BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
|
|
@@ -1763,14 +1871,14 @@ async function runPreparedSpawn(params) {
|
|
|
1763
1871
|
...commitResidue(git),
|
|
1764
1872
|
telemetry: observation.snapshot(),
|
|
1765
1873
|
};
|
|
1766
|
-
const terminal = await
|
|
1874
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1767
1875
|
kind: "complete",
|
|
1768
1876
|
send: () => httpClient.complete(job, completion),
|
|
1769
1877
|
deps,
|
|
1770
1878
|
options,
|
|
1771
1879
|
ownership,
|
|
1772
1880
|
log: deps.log,
|
|
1773
|
-
});
|
|
1881
|
+
}, job, control);
|
|
1774
1882
|
await finalizeRegistry();
|
|
1775
1883
|
return terminalToRunResult(terminal, "completed");
|
|
1776
1884
|
}
|
|
@@ -1800,7 +1908,7 @@ async function runPreparedSpawn(params) {
|
|
|
1800
1908
|
});
|
|
1801
1909
|
if (!finalization.ok) {
|
|
1802
1910
|
await finalizeRegistry();
|
|
1803
|
-
const terminal = await
|
|
1911
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1804
1912
|
kind: "fail",
|
|
1805
1913
|
send: () => httpClient.fail(job, {
|
|
1806
1914
|
...finalization.failure,
|
|
@@ -1811,7 +1919,7 @@ async function runPreparedSpawn(params) {
|
|
|
1811
1919
|
options,
|
|
1812
1920
|
ownership,
|
|
1813
1921
|
log: deps.log,
|
|
1814
|
-
});
|
|
1922
|
+
}, job, control);
|
|
1815
1923
|
return terminalToRunResult(terminal, "failed");
|
|
1816
1924
|
}
|
|
1817
1925
|
// BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
|
|
@@ -1834,14 +1942,14 @@ async function runPreparedSpawn(params) {
|
|
|
1834
1942
|
...commitResidue(git),
|
|
1835
1943
|
telemetry: observation.snapshot(),
|
|
1836
1944
|
};
|
|
1837
|
-
const terminal = await
|
|
1945
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1838
1946
|
kind: "complete",
|
|
1839
1947
|
send: () => httpClient.complete(job, completion),
|
|
1840
1948
|
deps,
|
|
1841
1949
|
options,
|
|
1842
1950
|
ownership,
|
|
1843
1951
|
log: deps.log,
|
|
1844
|
-
});
|
|
1952
|
+
}, job, control);
|
|
1845
1953
|
await finalizeRegistry();
|
|
1846
1954
|
return terminalToRunResult(terminal, "completed");
|
|
1847
1955
|
}
|
|
@@ -1863,7 +1971,7 @@ async function runPreparedSpawn(params) {
|
|
|
1863
1971
|
const authDetection = adapter.authFailureDetection;
|
|
1864
1972
|
const authFailure = authDetection?.supported === true &&
|
|
1865
1973
|
authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
|
|
1866
|
-
const terminal = await
|
|
1974
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1867
1975
|
kind: "fail",
|
|
1868
1976
|
send: () => httpClient.fail(job, {
|
|
1869
1977
|
error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
|
|
@@ -1878,6 +1986,6 @@ async function runPreparedSpawn(params) {
|
|
|
1878
1986
|
options,
|
|
1879
1987
|
ownership,
|
|
1880
1988
|
log: deps.log,
|
|
1881
|
-
});
|
|
1989
|
+
}, job, control);
|
|
1882
1990
|
return terminalToRunResult(terminal, "failed");
|
|
1883
1991
|
}
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
|
|
25
25
|
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
26
|
+
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
26
27
|
import { secretFreeErrorMessage } from "./job-errors.js";
|
|
28
|
+
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
27
29
|
function positiveIntOrNull(value) {
|
|
28
30
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
29
31
|
}
|
|
@@ -38,8 +40,21 @@ function asString(value) {
|
|
|
38
40
|
* Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
|
|
39
41
|
* over any payload copy, validates `pr_number` as a positive integer, and fails
|
|
40
42
|
* (before any subprocess) when the PR number or expected head SHA is absent.
|
|
43
|
+
*
|
|
44
|
+
* BAPI-1021 (AC-11): also resolves the two branch-ref expectations the local
|
|
45
|
+
* merge executor's ref-verification guard needs, and fails closed — before any
|
|
46
|
+
* `makeLocalMergeExecutor` construction or `runCommand` seam is reached — when
|
|
47
|
+
* either cannot be resolved:
|
|
48
|
+
*
|
|
49
|
+
* - `expectedBaseBranch` via {@link resolveExecutorJobBaseBranch}, the SAME
|
|
50
|
+
* resolver every other PR-producing job type uses, with `repositoryBaseBranch`
|
|
51
|
+
* as its documented legacy fallback for a payload omitting `base_branch`.
|
|
52
|
+
* - `expectedHeadBranch` EXCLUSIVELY from a trimmed, nonblank
|
|
53
|
+
* `job.expected_branch` — never from payload data, which is untrusted and
|
|
54
|
+
* job-type-generic. There is no fallback: a merge job with no known expected
|
|
55
|
+
* head branch cannot be ref-verified.
|
|
41
56
|
*/
|
|
42
|
-
export function readMergeJobPayloadFields(job) {
|
|
57
|
+
export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
|
|
43
58
|
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
|
|
44
59
|
const prNumber = positiveIntOrNull(payload.pr_number);
|
|
45
60
|
if (prNumber === null) {
|
|
@@ -49,6 +64,15 @@ export function readMergeJobPayloadFields(job) {
|
|
|
49
64
|
if (!expectedHeadSha) {
|
|
50
65
|
return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
|
|
51
66
|
}
|
|
67
|
+
const baseResolution = resolveExecutorJobBaseBranch(job, repositoryBaseBranch);
|
|
68
|
+
if (!baseResolution.ok) {
|
|
69
|
+
return { ok: false, error: baseResolution.error };
|
|
70
|
+
}
|
|
71
|
+
const expectedBaseBranch = baseResolution.baseBranch;
|
|
72
|
+
const expectedHeadBranch = asString(job.expected_branch ?? undefined);
|
|
73
|
+
if (!expectedHeadBranch) {
|
|
74
|
+
return { ok: false, error: "merge job requires a nonblank expected_branch" };
|
|
75
|
+
}
|
|
52
76
|
const method = resolveLocalMergeMethod(payload.method);
|
|
53
77
|
const requiredChecks = Array.isArray(payload.required_checks)
|
|
54
78
|
? payload.required_checks.filter((c) => typeof c === "string")
|
|
@@ -58,7 +82,17 @@ export function readMergeJobPayloadFields(job) {
|
|
|
58
82
|
const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
|
|
59
83
|
return {
|
|
60
84
|
ok: true,
|
|
61
|
-
fields: {
|
|
85
|
+
fields: {
|
|
86
|
+
prNumber,
|
|
87
|
+
expectedHeadSha,
|
|
88
|
+
method,
|
|
89
|
+
requiredChecks,
|
|
90
|
+
actionKey,
|
|
91
|
+
ciWaitTimeoutMs,
|
|
92
|
+
ciWaitPollIntervalMs,
|
|
93
|
+
expectedBaseBranch,
|
|
94
|
+
expectedHeadBranch,
|
|
95
|
+
},
|
|
62
96
|
};
|
|
63
97
|
}
|
|
64
98
|
/** Construct the {@link ConductorMergeRequest} for the local merge executor. */
|
|
@@ -120,10 +154,18 @@ export function buildMergeJobResult(response, fields) {
|
|
|
120
154
|
* safe to re-fire the same merge later.
|
|
121
155
|
* - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
|
|
122
156
|
* - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
|
|
157
|
+
* - {@link MERGE_REF_MISMATCH}: BAPI-1021 — a DELIBERATE, terminal safety
|
|
158
|
+
* refusal: the PR's current base or head branch did not match the caller's
|
|
159
|
+
* expectation, so no provider merge was attempted. This is neither
|
|
160
|
+
* retryable (the mismatch will not resolve itself) nor a rebase target (it
|
|
161
|
+
* is not a conflict) — see `_merge_entry_failure_is_retryable` and
|
|
162
|
+
* `_merge_entry_failure_requires_rebase` on the backend gate-retry path,
|
|
163
|
+
* which both explicitly exclude it.
|
|
123
164
|
*/
|
|
124
165
|
export const MERGE_RETRYABLE = "MergeRetryable";
|
|
125
166
|
export const MERGE_CONFLICT = "MergeConflict";
|
|
126
167
|
export const MERGE_FAILED = "MergeFailed";
|
|
168
|
+
export const MERGE_REF_MISMATCH = "MergeRefMismatch";
|
|
127
169
|
/** Reasons whose failure is transient/infrastructure and safe to retry. */
|
|
128
170
|
const RETRYABLE_MERGE_REASONS = new Set([
|
|
129
171
|
"gh_pr_view_timeout",
|
|
@@ -147,10 +189,18 @@ const RETRYABLE_MERGE_REASONS = new Set([
|
|
|
147
189
|
* `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
|
|
148
190
|
* variants covered so a distinct reason never accidentally downgrades to
|
|
149
191
|
* {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
|
|
192
|
+
*
|
|
193
|
+
* BAPI-1021: `ref_mismatch` is classified BEFORE conflict, retryable-prefix, and
|
|
194
|
+
* generic-failure handling, and is deliberately NEVER added to
|
|
195
|
+
* {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
|
|
196
|
+
* transient or conflict-shaped failure, and must not be re-firable by any
|
|
197
|
+
* future classification change.
|
|
150
198
|
*/
|
|
151
199
|
export function classifyMergeFailureErrorKind(response) {
|
|
152
200
|
const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
|
|
153
201
|
const reason = asString(response.reason ?? undefined);
|
|
202
|
+
if (reason === "ref_mismatch")
|
|
203
|
+
return MERGE_REF_MISMATCH;
|
|
154
204
|
if (hasConflictEvent || reason === "gh_merge_conflict")
|
|
155
205
|
return MERGE_CONFLICT;
|
|
156
206
|
if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
|
|
@@ -164,22 +214,59 @@ export function classifyMergeFailureErrorKind(response) {
|
|
|
164
214
|
* `error_message` includes only the sanitized merge status and reason (never raw
|
|
165
215
|
* `gh` output). `classification` stays within the executor's vocabulary; the
|
|
166
216
|
* `error_kind` is the routing signal.
|
|
217
|
+
*
|
|
218
|
+
* BAPI-1021: a {@link MERGE_REF_MISMATCH} classifies as `classification:
|
|
219
|
+
* "clean_exit"` rather than `"crashed"` — it is a deliberate refusal the
|
|
220
|
+
* executor made correctly, not a crash. Every other merge outcome keeps its
|
|
221
|
+
* existing `"crashed"` classification.
|
|
167
222
|
*/
|
|
168
223
|
export function buildMergeJobFailure(response) {
|
|
169
224
|
const reason = asString(response.reason ?? undefined) ?? response.status;
|
|
225
|
+
const errorKind = classifyMergeFailureErrorKind(response);
|
|
170
226
|
return {
|
|
171
|
-
error_kind:
|
|
227
|
+
error_kind: errorKind,
|
|
172
228
|
error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
|
|
173
|
-
classification: "crashed",
|
|
229
|
+
classification: errorKind === MERGE_REF_MISMATCH ? "clean_exit" : "crashed",
|
|
174
230
|
};
|
|
175
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Post-merge draft epic-integration-PR retry (BAPI-951). Invoked ONLY after a
|
|
234
|
+
* successful ticket-bound merge whose child PR based on `epic/<EPIC>` — never
|
|
235
|
+
* for a `main`/other-based merge, and never for any failed merge. Wrapped in
|
|
236
|
+
* its own try/catch (the shared helper is already non-throwing, but this
|
|
237
|
+
* guards against a future/injected implementation replacing an already-decided
|
|
238
|
+
* merge outcome). NEVER changes the returned result, the `error_kind`, or the
|
|
239
|
+
* `/complete` payload — only emits a sanitized advisory line.
|
|
240
|
+
*/
|
|
241
|
+
async function tryEnsurePostMergeEpicIntegrationPr(seams, childBaseBranch) {
|
|
242
|
+
try {
|
|
243
|
+
if (!childBaseBranch.startsWith(EPIC_BRANCH_PREFIX))
|
|
244
|
+
return;
|
|
245
|
+
const epicKey = childBaseBranch.slice(EPIC_BRANCH_PREFIX.length);
|
|
246
|
+
if (epicKey.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
|
|
249
|
+
const outcome = await ensureEpicIntegrationPullRequest({
|
|
250
|
+
epicKey,
|
|
251
|
+
epicBranch: childBaseBranch,
|
|
252
|
+
baseBranch: seams.repositoryBaseBranch,
|
|
253
|
+
command: "executor merge",
|
|
254
|
+
gh,
|
|
255
|
+
cwd: seams.cwd,
|
|
256
|
+
});
|
|
257
|
+
seams.epicIntegrationAdvisoryLog?.(`epic integration pr: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Advisory-only: never allowed to affect the already-decided merge outcome.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
176
263
|
/**
|
|
177
264
|
* Run the deterministic merge. NEVER ensures/recreates a worktree and NEVER
|
|
178
265
|
* spawns a worker. Returns a completion result on a succeeded merge, else a
|
|
179
266
|
* bounded, secret-free executor failure.
|
|
180
267
|
*/
|
|
181
268
|
export async function runExecutorMergeJob(job, seams) {
|
|
182
|
-
const resolution = readMergeJobPayloadFields(job);
|
|
269
|
+
const resolution = readMergeJobPayloadFields(job, seams.repositoryBaseBranch);
|
|
183
270
|
if (!resolution.ok) {
|
|
184
271
|
return {
|
|
185
272
|
ok: false,
|
|
@@ -197,6 +284,11 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
197
284
|
method: fields.method,
|
|
198
285
|
ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
|
|
199
286
|
ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
|
|
287
|
+
// BAPI-1021 (AC-11): pass the already-resolved expectations through
|
|
288
|
+
// unchanged so verification and the post-merge enrichment below use one
|
|
289
|
+
// authoritative value — neither re-resolves it independently.
|
|
290
|
+
expectedBaseBranch: fields.expectedBaseBranch,
|
|
291
|
+
expectedHeadBranch: fields.expectedHeadBranch,
|
|
200
292
|
}, seams.localMergeDeps);
|
|
201
293
|
let response;
|
|
202
294
|
try {
|
|
@@ -213,7 +305,11 @@ export async function runExecutorMergeJob(job, seams) {
|
|
|
213
305
|
};
|
|
214
306
|
}
|
|
215
307
|
if (response.status === "succeeded") {
|
|
216
|
-
|
|
308
|
+
// Build the successful outcome COMPLETELY first — enrichment can only ever
|
|
309
|
+
// add an advisory log line, never alter what is returned.
|
|
310
|
+
const result = buildMergeJobResult(response, fields);
|
|
311
|
+
await tryEnsurePostMergeEpicIntegrationPr(seams, fields.expectedBaseBranch);
|
|
312
|
+
return { ok: true, result };
|
|
217
313
|
}
|
|
218
314
|
return { ok: false, failure: buildMergeJobFailure(response) };
|
|
219
315
|
}
|