@bridge_gpt/mcp-server 0.2.54 → 0.2.55
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 +38 -54
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +5 -4
- package/build/conductor/bridge-api-client.js +199 -6
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +1 -1
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +37 -6
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +77 -17
- package/build/drive-epic.js +541 -115
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +274 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +586 -138
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +7 -7
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +36 -5
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +4 -4
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +1 -1
- package/build/setup-epic.js +69 -31
- package/build/start-tickets-conductor.js +8 -7
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/package.json +5 -3
|
@@ -40,6 +40,8 @@ import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
|
40
40
|
import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
41
41
|
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
42
42
|
import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
43
|
+
import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
|
|
44
|
+
import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
|
|
43
45
|
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
44
46
|
import { resolveExecutorJobIndexScope } from "./index-scope.js";
|
|
45
47
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
@@ -613,6 +615,21 @@ control) {
|
|
|
613
615
|
if (job.job_type === "smoke") {
|
|
614
616
|
return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
|
|
615
617
|
}
|
|
618
|
+
// BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
|
|
619
|
+
// and before the generic spawn path. It needs a prepared, locked worktree like
|
|
620
|
+
// a spawn job, but its body is the reconcile module's state machine: the
|
|
621
|
+
// payload is validated there BEFORE any subprocess, and the worker — when one
|
|
622
|
+
// is needed at all — is launched through the SAME locked `runSpawnJob` path
|
|
623
|
+
// via a driver, so there is still exactly one worker spawn boundary.
|
|
624
|
+
if (job.job_type === "reconcile_overlap") {
|
|
625
|
+
return await runReconcileOverlapJob(job, {
|
|
626
|
+
fail: async (failure) => {
|
|
627
|
+
await httpClient.fail(job, failure);
|
|
628
|
+
return { status: "failed", reason: "reconcile_overlap_payload" };
|
|
629
|
+
},
|
|
630
|
+
runLockedSpawn: (driver) => runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver),
|
|
631
|
+
}, buildReconcileOverlapSeams(deps));
|
|
632
|
+
}
|
|
616
633
|
// `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
|
|
617
634
|
// `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
|
|
618
635
|
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
@@ -646,6 +663,18 @@ control) {
|
|
|
646
663
|
* from the supervised `ProcessRunResult`/`ownership`, not from this adapter).
|
|
647
664
|
*/
|
|
648
665
|
function createMergeFlowProcess(startMerge) {
|
|
666
|
+
const flow = createFlowProcess(startMerge, (result) => result.ok);
|
|
667
|
+
return { proc: flow.proc, getOutcome: flow.getOutcome, getError: flow.getError };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* The generic form of {@link createMergeFlowProcess} (BAPI-1152): adapt any
|
|
671
|
+
* in-process flow into an {@link OwnedChildProcess} so it is supervised by the
|
|
672
|
+
* SAME heartbeat/deadman/timeout path as a worker. `settled` resolves once the
|
|
673
|
+
* flow's own promise has settled — after a timeout or kill the supervised
|
|
674
|
+
* process resolves immediately, but the flow may still be finishing a bounded
|
|
675
|
+
* subprocess, and a caller that is about to reset the worktree must wait for it.
|
|
676
|
+
*/
|
|
677
|
+
function createFlowProcess(start, isOk) {
|
|
649
678
|
const controller = new AbortController();
|
|
650
679
|
let outcome;
|
|
651
680
|
let error;
|
|
@@ -654,12 +683,12 @@ function createMergeFlowProcess(startMerge) {
|
|
|
654
683
|
const waitPromise = new Promise((resolve) => {
|
|
655
684
|
settleWait = resolve;
|
|
656
685
|
});
|
|
657
|
-
// Start the
|
|
658
|
-
|
|
686
|
+
// Start the flow immediately; capture its resolution/rejection.
|
|
687
|
+
const settled = start(controller.signal).then((result) => {
|
|
659
688
|
outcome = result;
|
|
660
689
|
if (!done) {
|
|
661
690
|
done = true;
|
|
662
|
-
settleWait({ exitCode: result
|
|
691
|
+
settleWait({ exitCode: isOk(result) ? 0 : 1, signal: null });
|
|
663
692
|
}
|
|
664
693
|
}, (err) => {
|
|
665
694
|
error = err;
|
|
@@ -687,6 +716,7 @@ function createMergeFlowProcess(startMerge) {
|
|
|
687
716
|
proc,
|
|
688
717
|
getOutcome: () => outcome,
|
|
689
718
|
getError: () => error,
|
|
719
|
+
settled,
|
|
690
720
|
};
|
|
691
721
|
}
|
|
692
722
|
/**
|
|
@@ -884,6 +914,131 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
884
914
|
}, job, control);
|
|
885
915
|
return terminalToRunResult(terminal, "completed");
|
|
886
916
|
}
|
|
917
|
+
/** Build the reconcile module's seams from executor deps (BAPI-1152). */
|
|
918
|
+
function buildReconcileOverlapSeams(deps) {
|
|
919
|
+
return {
|
|
920
|
+
runCommand: deps.runCommand,
|
|
921
|
+
readFile: deps.readFile,
|
|
922
|
+
writeFile: deps.writeFile,
|
|
923
|
+
mkdir: deps.mkdir,
|
|
924
|
+
now: deps.now,
|
|
925
|
+
sleep: deps.sleep,
|
|
926
|
+
env: deps.env,
|
|
927
|
+
pollProviderChecks: lazyProviderCheckPoller({
|
|
928
|
+
env: deps.env,
|
|
929
|
+
cwd: deps.cwd,
|
|
930
|
+
homedir: deps.homedir,
|
|
931
|
+
platform: deps.platform,
|
|
932
|
+
readFile: deps.readFile,
|
|
933
|
+
stat: deps.stat,
|
|
934
|
+
}),
|
|
935
|
+
errorLog: deps.errorLog,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
/** Bound on waiting for an aborted driver phase to finish its in-flight command. */
|
|
939
|
+
const DRIVER_PHASE_SETTLE_MAX_MS = 240_000;
|
|
940
|
+
/**
|
|
941
|
+
* Run one driver phase under the SAME heartbeat/deadman/timeout supervision a
|
|
942
|
+
* worker gets (BAPI-1152). A deterministic phase can run for many minutes — local
|
|
943
|
+
* verification commands, a CI wait — and without heartbeats the janitor would
|
|
944
|
+
* re-queue a healthy job. On a timeout or a lost claim the phase is aborted and
|
|
945
|
+
* then AWAITED (bounded) before returning, so the caller never resets the
|
|
946
|
+
* worktree underneath a git command the phase is still running.
|
|
947
|
+
*/
|
|
948
|
+
async function superviseDriverPhase(params) {
|
|
949
|
+
const flow = createFlowProcess(params.run, () => true);
|
|
950
|
+
const procResult = await superviseProcess({
|
|
951
|
+
job: params.job,
|
|
952
|
+
httpClient: params.httpClient,
|
|
953
|
+
options: params.options,
|
|
954
|
+
deps: params.deps,
|
|
955
|
+
ownership: params.ownership,
|
|
956
|
+
observation: params.observation,
|
|
957
|
+
proc: flow.proc,
|
|
958
|
+
timeoutSeconds: Math.max(1, params.timeoutSeconds),
|
|
959
|
+
collectTelemetry: params.collectTelemetry,
|
|
960
|
+
control: params.control,
|
|
961
|
+
});
|
|
962
|
+
await Promise.race([flow.settled, params.deps.sleep(DRIVER_PHASE_SETTLE_MAX_MS)]);
|
|
963
|
+
if (procResult.serverStopRequested)
|
|
964
|
+
return { kind: "server_stop" };
|
|
965
|
+
if (params.ownership.abandoned)
|
|
966
|
+
return { kind: "abandoned" };
|
|
967
|
+
if (procResult.classification === "timeout")
|
|
968
|
+
return { kind: "timeout" };
|
|
969
|
+
const error = flow.getError();
|
|
970
|
+
if (error !== undefined)
|
|
971
|
+
return { kind: "error", error };
|
|
972
|
+
const value = flow.getOutcome();
|
|
973
|
+
if (value === undefined)
|
|
974
|
+
return { kind: "error", error: new Error("driver phase produced no outcome") };
|
|
975
|
+
return { kind: "result", value };
|
|
976
|
+
}
|
|
977
|
+
/** Report a driver's terminal decision through the shared terminal-mutation path. */
|
|
978
|
+
async function reportDriverOutcome(outcome, ctx) {
|
|
979
|
+
if (outcome.kind === "complete") {
|
|
980
|
+
const completion = {
|
|
981
|
+
job_type: ctx.job.job_type,
|
|
982
|
+
exit_code: 0,
|
|
983
|
+
classification: "clean_exit",
|
|
984
|
+
result: outcome.result,
|
|
985
|
+
// ONLY a pushed head is reported: the server binds `last_commit_sha` as the
|
|
986
|
+
// job's produced head, and a job that pushed nothing produced none.
|
|
987
|
+
...(outcome.lastCommitSha ? { last_commit_sha: outcome.lastCommitSha } : {}),
|
|
988
|
+
telemetry: ctx.observation.snapshot(),
|
|
989
|
+
};
|
|
990
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
991
|
+
kind: "complete",
|
|
992
|
+
send: () => ctx.httpClient.complete(ctx.job, completion),
|
|
993
|
+
deps: ctx.deps,
|
|
994
|
+
options: ctx.options,
|
|
995
|
+
ownership: ctx.ownership,
|
|
996
|
+
log: ctx.deps.log,
|
|
997
|
+
}, ctx.job, ctx.control);
|
|
998
|
+
return terminalToRunResult(terminal, "completed");
|
|
999
|
+
}
|
|
1000
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1001
|
+
kind: "fail",
|
|
1002
|
+
send: () => ctx.httpClient.fail(ctx.job, { ...outcome.failure, telemetry: ctx.observation.snapshot() }),
|
|
1003
|
+
deps: ctx.deps,
|
|
1004
|
+
options: ctx.options,
|
|
1005
|
+
ownership: ctx.ownership,
|
|
1006
|
+
log: ctx.deps.log,
|
|
1007
|
+
}, ctx.job, ctx.control);
|
|
1008
|
+
return terminalToRunResult(terminal, "failed");
|
|
1009
|
+
}
|
|
1010
|
+
/** Restore the worktree and report a driver phase that did not finish. */
|
|
1011
|
+
async function reportDriverPhaseInterruption(phase, ctx) {
|
|
1012
|
+
await ctx.driver.abandon(ctx.driverBaseContext);
|
|
1013
|
+
if (phase.kind === "abandoned") {
|
|
1014
|
+
return { status: "abandoned", reason: ctx.ownership.abandonReason };
|
|
1015
|
+
}
|
|
1016
|
+
if (phase.kind === "server_stop") {
|
|
1017
|
+
await ctx.httpClient.fail(ctx.job, {
|
|
1018
|
+
error_kind: "ServerStopRequested",
|
|
1019
|
+
error_message: "the server requested this run stop; the reconciliation was stopped",
|
|
1020
|
+
classification: "killed",
|
|
1021
|
+
telemetry: ctx.observation.snapshot(),
|
|
1022
|
+
});
|
|
1023
|
+
ctx.ownership.abandoned = true;
|
|
1024
|
+
ctx.ownership.abandonReason = "server_stop";
|
|
1025
|
+
return { status: "abandoned", reason: "server_stop" };
|
|
1026
|
+
}
|
|
1027
|
+
return await reportDriverOutcome({
|
|
1028
|
+
kind: "fail",
|
|
1029
|
+
failure: phase.kind === "timeout"
|
|
1030
|
+
? {
|
|
1031
|
+
error_kind: "Timeout",
|
|
1032
|
+
error_message: `${ctx.job.job_type} phase exceeded the job timeout`,
|
|
1033
|
+
classification: "timeout",
|
|
1034
|
+
}
|
|
1035
|
+
: {
|
|
1036
|
+
error_kind: "WorkerCrashed",
|
|
1037
|
+
error_message: `${ctx.job.job_type} phase failed: ${secretFreeErrorMessage(phase.error)}`,
|
|
1038
|
+
classification: "crashed",
|
|
1039
|
+
},
|
|
1040
|
+
}, ctx);
|
|
1041
|
+
}
|
|
887
1042
|
/**
|
|
888
1043
|
* BAPI-862: the payload key the reconciler stamps with the implement gate's last
|
|
889
1044
|
* observation reason code. Server-minted and opaque to the executor beyond the
|
|
@@ -1048,7 +1203,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1048
1203
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
1049
1204
|
* touches a worktree that will not become a runnable worker.
|
|
1050
1205
|
*/
|
|
1051
|
-
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control
|
|
1206
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
|
|
1207
|
+
/**
|
|
1208
|
+
* BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
|
|
1209
|
+
* the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
|
|
1210
|
+
* adds no second worktree entry and no second worker launcher.
|
|
1211
|
+
*/
|
|
1212
|
+
driver) {
|
|
1052
1213
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
1053
1214
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
1054
1215
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -1239,6 +1400,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1239
1400
|
adapter,
|
|
1240
1401
|
control,
|
|
1241
1402
|
lifecycle: spawnLifecycle,
|
|
1403
|
+
driver,
|
|
1242
1404
|
});
|
|
1243
1405
|
}
|
|
1244
1406
|
finally {
|
|
@@ -1273,12 +1435,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1273
1435
|
* rather than a 500-line indentation nobody can verify by eye.
|
|
1274
1436
|
*/
|
|
1275
1437
|
async function runPreparedSpawn(params) {
|
|
1276
|
-
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
|
|
1438
|
+
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, driver, } = params;
|
|
1277
1439
|
// BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
|
|
1278
1440
|
// are told to open the PR against the injected run base. Verdict-only
|
|
1279
1441
|
// `spec_review` produces no PR, so it is excluded from the instruction while
|
|
1280
1442
|
// still using the pinned base for its fresh worktree.
|
|
1281
|
-
|
|
1443
|
+
let prompt = isImplementationStyleJobType(job.job_type)
|
|
1282
1444
|
? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
|
|
1283
1445
|
: prep.prompt;
|
|
1284
1446
|
// --- Timeout contract ------------------------------------------------
|
|
@@ -1291,6 +1453,49 @@ async function runPreparedSpawn(params) {
|
|
|
1291
1453
|
});
|
|
1292
1454
|
return { status: "failed", reason: "timeout_contract" };
|
|
1293
1455
|
}
|
|
1456
|
+
// --- Spawn-job driver: the pre-worker phase (BAPI-1152) --------------
|
|
1457
|
+
// Runs INSIDE the lock and before every worker-owned side effect. A driver may
|
|
1458
|
+
// finish the whole job here (a clean, green reconciliation pushes with no
|
|
1459
|
+
// worker at all), or hand back the ONE worker it needs and the pre-spawn state
|
|
1460
|
+
// it has already proven. The job's overall timeout budget spans every phase.
|
|
1461
|
+
const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
|
|
1462
|
+
const remainingMs = () => Math.max(0, jobDeadlineMs - deps.now());
|
|
1463
|
+
const driverBaseContext = {
|
|
1464
|
+
worktreePath,
|
|
1465
|
+
branch,
|
|
1466
|
+
preparedHeadSha: prep.expectedHeadSha,
|
|
1467
|
+
effectiveBaseBranch,
|
|
1468
|
+
};
|
|
1469
|
+
const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
1470
|
+
let driverStep;
|
|
1471
|
+
if (driver) {
|
|
1472
|
+
const phase = await superviseDriverPhase({
|
|
1473
|
+
job,
|
|
1474
|
+
httpClient,
|
|
1475
|
+
options: jobOptions,
|
|
1476
|
+
deps,
|
|
1477
|
+
ownership,
|
|
1478
|
+
observation,
|
|
1479
|
+
control,
|
|
1480
|
+
timeoutSeconds: Math.ceil(remainingMs() / 1000),
|
|
1481
|
+
collectTelemetry: driverTelemetry,
|
|
1482
|
+
run: (signal) => driver.beforeWorker({ ...driverBaseContext, signal, remainingMs }),
|
|
1483
|
+
});
|
|
1484
|
+
if (phase.kind !== "result") {
|
|
1485
|
+
return await reportDriverPhaseInterruption(phase, {
|
|
1486
|
+
job, httpClient, options, deps, ownership, observation, control, driver, driverBaseContext,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
if (phase.value.kind === "finish") {
|
|
1490
|
+
return await reportDriverOutcome(phase.value.outcome, {
|
|
1491
|
+
job, httpClient, options, deps, ownership, observation, control,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
driverStep = phase.value;
|
|
1495
|
+
// The driver's prompt is used VERBATIM: it is server-rendered, and the PR-base
|
|
1496
|
+
// launch instruction would tell a worker with no push authority to open a PR.
|
|
1497
|
+
prompt = driverStep.prompt;
|
|
1498
|
+
}
|
|
1294
1499
|
// --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
|
|
1295
1500
|
// FIRST of the pre-spawn contracts, and deliberately BEFORE every
|
|
1296
1501
|
// executor-owned write to the worktree (deny layer, MCP provisioning, worker
|
|
@@ -1304,8 +1509,15 @@ async function runPreparedSpawn(params) {
|
|
|
1304
1509
|
// untracked-clean / head-verify behavior in `worktree-core.ts`; it is an
|
|
1305
1510
|
// additional independent check on top of them.
|
|
1306
1511
|
const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
1307
|
-
|
|
1512
|
+
// BAPI-1152: a driver proves its own pre-spawn state (a reconciliation worker
|
|
1513
|
+
// may start inside an in-progress merge, which the generic "clean at the
|
|
1514
|
+
// prepared commit" check would refuse by design).
|
|
1515
|
+
const verification = driverStep
|
|
1516
|
+
? driverStep.preSpawnVerification
|
|
1517
|
+
: evaluatePreSpawnGitVerification(preSpawnTelemetry, prep.expectedHeadSha);
|
|
1308
1518
|
if (!verification.ok) {
|
|
1519
|
+
if (driver)
|
|
1520
|
+
await driver.abandon(driverBaseContext);
|
|
1309
1521
|
// Record the attempt boundary even for a refusal: an attempt that never
|
|
1310
1522
|
// spawned still started and ended at the observed commit, so the reconciler
|
|
1311
1523
|
// sees a machine-readable "went nowhere" rather than a silent gap.
|
|
@@ -1555,7 +1767,12 @@ async function runPreparedSpawn(params) {
|
|
|
1555
1767
|
// BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
|
|
1556
1768
|
// is the deterministic guard's only source of branch identity. Same
|
|
1557
1769
|
// provenance as the deny-provisioning value above: the prepared worktree.
|
|
1558
|
-
|
|
1770
|
+
//
|
|
1771
|
+
// BAPI-1152: a driver that holds the push itself OMITS it, so the guard
|
|
1772
|
+
// denies every `git push` the worker attempts — the worker has no remote
|
|
1773
|
+
// write authority at all.
|
|
1774
|
+
...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
|
|
1775
|
+
...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
|
|
1559
1776
|
parentEnv: deps.env,
|
|
1560
1777
|
});
|
|
1561
1778
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -1736,7 +1953,9 @@ async function runPreparedSpawn(params) {
|
|
|
1736
1953
|
ownership,
|
|
1737
1954
|
observation,
|
|
1738
1955
|
proc: supervisedProc,
|
|
1739
|
-
|
|
1956
|
+
// BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
|
|
1957
|
+
// pre-worker phase has already spent some of it.
|
|
1958
|
+
timeoutSeconds: driver ? Math.max(1, Math.ceil(remainingMs() / 1000)) : timeout.timeoutSeconds,
|
|
1740
1959
|
collectTelemetry,
|
|
1741
1960
|
collectRemoteMarker,
|
|
1742
1961
|
mcpSurface,
|
|
@@ -1786,6 +2005,8 @@ async function runPreparedSpawn(params) {
|
|
|
1786
2005
|
if (procResult.serverStopRequested) {
|
|
1787
2006
|
await closeWorkerLog();
|
|
1788
2007
|
await finalizeRegistry();
|
|
2008
|
+
if (driver)
|
|
2009
|
+
await driver.abandon(driverBaseContext);
|
|
1789
2010
|
await httpClient.fail(job, {
|
|
1790
2011
|
error_kind: "ServerStopRequested",
|
|
1791
2012
|
error_message: "the server requested this run stop; the owned worker was terminated",
|
|
@@ -1813,6 +2034,8 @@ async function runPreparedSpawn(params) {
|
|
|
1813
2034
|
await reportWorkerLogSessionInvariant(tee, deps);
|
|
1814
2035
|
if (ownership.abandoned) {
|
|
1815
2036
|
await finalizeRegistry();
|
|
2037
|
+
if (driver)
|
|
2038
|
+
await driver.abandon(driverBaseContext);
|
|
1816
2039
|
return { status: "abandoned", reason: ownership.abandonReason };
|
|
1817
2040
|
}
|
|
1818
2041
|
// --- MCP surface containment verdict (FAIL-CLOSED, BAPI-790) ----------
|
|
@@ -1833,6 +2056,8 @@ async function runPreparedSpawn(params) {
|
|
|
1833
2056
|
const surfaceVerdict = mcpSurface.verdict();
|
|
1834
2057
|
if (surfaceVerdict.kind === "mismatch") {
|
|
1835
2058
|
await finalizeRegistry();
|
|
2059
|
+
if (driver)
|
|
2060
|
+
await driver.abandon(driverBaseContext);
|
|
1836
2061
|
await httpClient.fail(job, {
|
|
1837
2062
|
error_kind: McpSurfaceMismatch,
|
|
1838
2063
|
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
@@ -1860,6 +2085,8 @@ async function runPreparedSpawn(params) {
|
|
|
1860
2085
|
procResult.classification === "crashed" &&
|
|
1861
2086
|
procResult.stdoutExcerpt.trim().length === 0) {
|
|
1862
2087
|
await finalizeRegistry();
|
|
2088
|
+
if (driver)
|
|
2089
|
+
await driver.abandon(driverBaseContext);
|
|
1863
2090
|
await httpClient.fail(job, {
|
|
1864
2091
|
error_kind: WorkerStartupFatal,
|
|
1865
2092
|
error_message: WORKER_STARTUP_FATAL_MESSAGE,
|
|
@@ -1886,6 +2113,31 @@ async function runPreparedSpawn(params) {
|
|
|
1886
2113
|
"for this spawn");
|
|
1887
2114
|
}
|
|
1888
2115
|
const git = observation.git();
|
|
2116
|
+
// BAPI-1152: a driver owns everything after a clean worker exit — staging, the
|
|
2117
|
+
// governance scan, the commits, the guarded push, and finalization.
|
|
2118
|
+
if (driver && procResult.classification === "clean_exit") {
|
|
2119
|
+
await finalizeRegistry();
|
|
2120
|
+
const phase = await superviseDriverPhase({
|
|
2121
|
+
job,
|
|
2122
|
+
httpClient,
|
|
2123
|
+
options: jobOptions,
|
|
2124
|
+
deps,
|
|
2125
|
+
ownership,
|
|
2126
|
+
observation,
|
|
2127
|
+
control,
|
|
2128
|
+
timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
|
|
2129
|
+
collectTelemetry: driverTelemetry,
|
|
2130
|
+
run: (signal) => driver.afterWorker({ ...driverBaseContext, signal, remainingMs }, { classification: procResult.classification, exitCode: procResult.exitCode }),
|
|
2131
|
+
});
|
|
2132
|
+
if (phase.kind !== "result") {
|
|
2133
|
+
return await reportDriverPhaseInterruption(phase, {
|
|
2134
|
+
job, httpClient, options, deps, ownership, observation, control, driver, driverBaseContext,
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
return await reportDriverOutcome(phase.value, {
|
|
2138
|
+
job, httpClient, options, deps, ownership, observation, control,
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
1889
2141
|
if (procResult.classification === "clean_exit") {
|
|
1890
2142
|
// Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
|
|
1891
2143
|
// missing/invalid artifact is a fail-loud `MissingVerdictArtifact`.
|
|
@@ -2028,6 +2280,19 @@ async function runPreparedSpawn(params) {
|
|
|
2028
2280
|
// a verdict and a reason code only — never the matched line — so nothing from
|
|
2029
2281
|
// the worker's stream reaches `/fail` through this path.
|
|
2030
2282
|
await finalizeRegistry();
|
|
2283
|
+
// BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
|
|
2284
|
+
// `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
|
|
2285
|
+
// the next job's reset fail. Removed for EVERY spawn job, never with a hard
|
|
2286
|
+
// reset; only a driver (`reconcile_overlap`) additionally resets to its anchor.
|
|
2287
|
+
if (procResult.classification === "timeout" || procResult.classification === "killed") {
|
|
2288
|
+
await cleanupInterruptedWorktree(worktreePath, {
|
|
2289
|
+
runCommand: deps.runCommand,
|
|
2290
|
+
removeFile: deps.removeFile ?? ((filePath) => rm(filePath)),
|
|
2291
|
+
errorLog: deps.errorLog,
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
if (driver)
|
|
2295
|
+
await driver.abandon(driverBaseContext);
|
|
2031
2296
|
const authDetection = adapter.authFailureDetection;
|
|
2032
2297
|
const authFailure = authDetection?.supported === true &&
|
|
2033
2298
|
authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Shared executor job-type policy (BAPI-542, TDD §9).
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for which claimed job types the executor spawns and
|
|
5
|
-
* which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase
|
|
6
|
-
* resume the ticket's existing branch/PR
|
|
7
|
-
*
|
|
5
|
+
* which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase`/
|
|
6
|
+
* `reconcile_overlap`) resume the ticket's existing branch/PR rather than cutting
|
|
7
|
+
* a fresh branch off base — the first three through the `/implement-ticket <KEY>
|
|
8
|
+
* --auto` correction loop, `reconcile_overlap` through its own job body. Dispatch
|
|
8
9
|
* (`job-runner.ts` `runClaimedJob`), prompt synthesis (`worker-command.ts`
|
|
9
10
|
* `resolveExecutorPrompt`), and worktree reuse (`job-runner.ts` `prepareSpawn` →
|
|
10
11
|
* `worktree.ts` `ensureExecutorWorktree`) all read from here so the recovery /
|
|
@@ -17,21 +18,33 @@
|
|
|
17
18
|
/**
|
|
18
19
|
* Recovery job types: enqueued by the server-side reconciler when a gate is
|
|
19
20
|
* ACTIONABLE (review changes requested → `remediate`, CI failed → `ci_fix`,
|
|
20
|
-
* merge conflict → `rebase
|
|
21
|
-
* EXISTING branch/PR, so the
|
|
22
|
-
* fresh one off base.
|
|
21
|
+
* merge conflict → `rebase`, overlapping sibling at `code_review` →
|
|
22
|
+
* `reconcile_overlap`). They work on the ticket's EXISTING branch/PR, so the
|
|
23
|
+
* executor reuses that branch instead of cutting a fresh one off base.
|
|
24
|
+
*
|
|
25
|
+
* BAPI-1152: `reconcile_overlap` is a recovery job for worktree reuse and prompt
|
|
26
|
+
* resolution, but it does NOT re-enter `/implement-ticket`: it always carries an
|
|
27
|
+
* explicit server-rendered `payload.prompt`, which wins over the synthesis in
|
|
28
|
+
* `worker-command.ts`, and `job-runner.ts` dispatches it to its own body.
|
|
23
29
|
*/
|
|
24
30
|
export const RECOVERY_JOB_TYPES = new Set([
|
|
25
31
|
"remediate",
|
|
26
32
|
"ci_fix",
|
|
27
33
|
"rebase",
|
|
34
|
+
"reconcile_overlap",
|
|
28
35
|
]);
|
|
29
36
|
/**
|
|
30
37
|
* Real headless-spawn job types (worktree + worker LLM): the first-pass
|
|
31
38
|
* `implement`, the `resume` continuation (own pre-spawn protocol), the
|
|
32
|
-
* `spec_review` verdict job, and the
|
|
39
|
+
* `spec_review` verdict job, and the recovery jobs. `merge` (deterministic,
|
|
33
40
|
* no worktree/worker) and `smoke` (no-op acceptance) are intentionally EXCLUDED —
|
|
34
41
|
* they have dedicated dispatch branches BEFORE the generic spawn path.
|
|
42
|
+
*
|
|
43
|
+
* BAPI-1152: `reconcile_overlap` is here because it needs a prepared, locked
|
|
44
|
+
* worktree and MAY spawn a worker — but only CONDITIONALLY. A clean merge whose
|
|
45
|
+
* local checks pass is pushed with no worker at all. It also has a dedicated
|
|
46
|
+
* dispatch branch ahead of the generic spawn path; membership here is what makes
|
|
47
|
+
* worktree preparation and `isImplementationStyleJobType` treat it as a spawn job.
|
|
35
48
|
*/
|
|
36
49
|
export const SPAWN_JOB_TYPES = new Set([
|
|
37
50
|
"implement",
|
|
@@ -40,6 +53,7 @@ export const SPAWN_JOB_TYPES = new Set([
|
|
|
40
53
|
"remediate",
|
|
41
54
|
"ci_fix",
|
|
42
55
|
"rebase",
|
|
56
|
+
"reconcile_overlap",
|
|
43
57
|
]);
|
|
44
58
|
/** True only for the exact lowercase recovery job types. */
|
|
45
59
|
export function isRecoveryJobType(jobType) {
|
|
@@ -52,8 +66,10 @@ export function isSpawnJobType(jobType) {
|
|
|
52
66
|
/**
|
|
53
67
|
* Implementation-style spawn jobs (BAPI-551): every spawn job type expected to
|
|
54
68
|
* end with a pushed origin branch or an opened PR — `implement`, `resume`, and
|
|
55
|
-
* the recovery jobs
|
|
56
|
-
*
|
|
69
|
+
* the recovery jobs (`reconcile_overlap` included: when it pushes, finalization
|
|
70
|
+
* verifies the push against origin). Excludes `spec_review`, the one spawn type
|
|
71
|
+
* that is a verdict job (it produces `.conductor/result.json` instead of a
|
|
72
|
+
* branch/PR).
|
|
57
73
|
*/
|
|
58
74
|
export function isImplementationStyleJobType(jobType) {
|
|
59
75
|
return isSpawnJobType(jobType) && jobType !== "spec_review";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic merge classification for `reconcile_overlap` (BAPI-1152).
|
|
3
|
+
*
|
|
4
|
+
* Classifies a (child head, required epic SHA) pair with
|
|
5
|
+
* `git merge-tree --write-tree` BEFORE anything touches the held worktree.
|
|
6
|
+
* `merge-tree` computes the merge entirely in the object database: it writes a
|
|
7
|
+
* tree object and reports conflicts, but never touches a working tree, an index,
|
|
8
|
+
* `HEAD`, or a ref. That is what lets the job decide "clean or conflicted" first
|
|
9
|
+
* and only then materialize a real merge.
|
|
10
|
+
*
|
|
11
|
+
* Output is read in the machine-readable `-z --name-only --messages` form, never
|
|
12
|
+
* scraped from human text. Verified against git 2.50.1:
|
|
13
|
+
*
|
|
14
|
+
* <tree-oid> NUL <conflicted path> NUL ... NUL NUL
|
|
15
|
+
* ( <N> NUL <path 1> NUL ... <path N> NUL <type> NUL <message> NUL )*
|
|
16
|
+
*
|
|
17
|
+
* with exit 0 for a clean merge and 1 for a conflicted one. Exit 1 is ALSO what
|
|
18
|
+
* git returns for an unknown commit — with no tree OID on stdout — so the exit
|
|
19
|
+
* code alone never decides "conflicted": the parsed tree OID does. Anything that
|
|
20
|
+
* is not a proven clean or conflicted result is `operational_error`, and the job
|
|
21
|
+
* fails rather than guessing.
|
|
22
|
+
*
|
|
23
|
+
* Every git call is an argv list through the injected `RunCommand` (`execFile`,
|
|
24
|
+
* `shell: false`); no shell string is ever built.
|
|
25
|
+
*/
|
|
26
|
+
import { normalizeRepoRelativePath } from "../conductor/file-scope-guard.js";
|
|
27
|
+
/** Bound on the conflicted paths recorded. More than this is still `conflicted`. */
|
|
28
|
+
export const MERGE_TREE_MAX_CONFLICTS = 200;
|
|
29
|
+
/** Bound on the informational lines kept per conflicted path. */
|
|
30
|
+
export const MERGE_TREE_MAX_MESSAGES_PER_PATH = 5;
|
|
31
|
+
/** Bound on one message line. */
|
|
32
|
+
export const MERGE_TREE_MESSAGE_MAX_CHARS = 300;
|
|
33
|
+
/** Wall-clock bound on each git call the classifier makes. */
|
|
34
|
+
export const MERGE_TREE_COMMAND_TIMEOUT_MS = 120_000;
|
|
35
|
+
const OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
36
|
+
function descriptorFor(type, message) {
|
|
37
|
+
// The MESSAGE carries git's user-facing spelling ("CONFLICT (content)"); the
|
|
38
|
+
// type token is an internal name ("CONFLICT (contents)"). Prefer the message's
|
|
39
|
+
// own prefix so the descriptor matches what an operator sees in `git merge`.
|
|
40
|
+
const fromMessage = /^(CONFLICT \([^)]*\))/.exec(message.trim());
|
|
41
|
+
if (fromMessage)
|
|
42
|
+
return fromMessage[1];
|
|
43
|
+
return type.trim().length > 0 ? type.trim() : "CONFLICT (unknown)";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse `git merge-tree --write-tree -z --name-only --messages` stdout.
|
|
47
|
+
*
|
|
48
|
+
* Exported for the unit tests; `classifyMergeTree` is the runtime entry point.
|
|
49
|
+
* Returns `null` when stdout does not start with a tree OID — the caller treats
|
|
50
|
+
* that as an operational error.
|
|
51
|
+
*/
|
|
52
|
+
export function parseMergeTreeOutput(stdout) {
|
|
53
|
+
const tokens = stdout.split("\0");
|
|
54
|
+
const treeOid = (tokens[0] ?? "").trim().toLowerCase();
|
|
55
|
+
if (!OID_RE.test(treeOid))
|
|
56
|
+
return null;
|
|
57
|
+
let i = 1;
|
|
58
|
+
const conflictedPaths = [];
|
|
59
|
+
while (i < tokens.length && tokens[i] !== "") {
|
|
60
|
+
conflictedPaths.push(tokens[i]);
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
i += 1; // the empty token separating conflicted paths from messages
|
|
64
|
+
const byPath = new Map();
|
|
65
|
+
const record = (rawPath, descriptor, message) => {
|
|
66
|
+
const path = normalizeRepoRelativePath(rawPath) ?? rawPath;
|
|
67
|
+
let entry = byPath.get(path);
|
|
68
|
+
if (!entry) {
|
|
69
|
+
entry = { path, descriptor: descriptor ?? "CONFLICT (unknown)", messages: [] };
|
|
70
|
+
byPath.set(path, entry);
|
|
71
|
+
}
|
|
72
|
+
else if (descriptor && entry.descriptor === "CONFLICT (unknown)") {
|
|
73
|
+
entry.descriptor = descriptor;
|
|
74
|
+
}
|
|
75
|
+
if (message && entry.messages.length < MERGE_TREE_MAX_MESSAGES_PER_PATH) {
|
|
76
|
+
entry.messages.push(message.trim().slice(0, MERGE_TREE_MESSAGE_MAX_CHARS));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
while (i < tokens.length) {
|
|
80
|
+
const countToken = tokens[i];
|
|
81
|
+
if (countToken === "" || countToken === undefined)
|
|
82
|
+
break;
|
|
83
|
+
const count = Number.parseInt(countToken, 10);
|
|
84
|
+
// A group is `count` paths plus a type and a message; a truncated group ends
|
|
85
|
+
// the parse rather than reading past stdout.
|
|
86
|
+
if (!Number.isInteger(count) || count < 0 || i + count + 2 >= tokens.length)
|
|
87
|
+
break;
|
|
88
|
+
const paths = tokens.slice(i + 1, i + 1 + count);
|
|
89
|
+
const type = tokens[i + 1 + count] ?? "";
|
|
90
|
+
const message = tokens[i + 2 + count] ?? "";
|
|
91
|
+
i += count + 3;
|
|
92
|
+
if (!type.startsWith("CONFLICT"))
|
|
93
|
+
continue;
|
|
94
|
+
const descriptor = descriptorFor(type, message);
|
|
95
|
+
// The FIRST path of a multi-path message (e.g. rename/delete names the new
|
|
96
|
+
// and the old path) is the one git reports as conflicted; the rest are
|
|
97
|
+
// context. Recording every named path would report a path that no longer
|
|
98
|
+
// exists in the merge result as conflicted.
|
|
99
|
+
const conflicted = paths.find((p) => conflictedPaths.includes(p)) ?? paths[0];
|
|
100
|
+
if (conflicted !== undefined)
|
|
101
|
+
record(conflicted, descriptor, message);
|
|
102
|
+
}
|
|
103
|
+
// A path git listed as conflicted with no CONFLICT message is still conflicted.
|
|
104
|
+
for (const path of conflictedPaths) {
|
|
105
|
+
const normalized = normalizeRepoRelativePath(path) ?? path;
|
|
106
|
+
if (!byPath.has(normalized))
|
|
107
|
+
record(path, null, null);
|
|
108
|
+
}
|
|
109
|
+
const conflicts = Array.from(byPath.values())
|
|
110
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
111
|
+
.slice(0, MERGE_TREE_MAX_CONFLICTS);
|
|
112
|
+
return { treeOid, conflicts };
|
|
113
|
+
}
|
|
114
|
+
/** Verify a commit object is present locally. */
|
|
115
|
+
async function commitPresent(runCommand, repoDir, sha) {
|
|
116
|
+
try {
|
|
117
|
+
const result = await runCommand("git", ["cat-file", "-e", `${sha}^{commit}`], {
|
|
118
|
+
cwd: repoDir,
|
|
119
|
+
timeoutMs: MERGE_TREE_COMMAND_TIMEOUT_MS,
|
|
120
|
+
});
|
|
121
|
+
return result.exitCode === 0;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Classify merging `requiredEpicSha` into `childHead`. Never mutates a worktree,
|
|
129
|
+
* an index, `HEAD`, or a ref. Both SHAs must already be present in `repoDir`'s
|
|
130
|
+
* object database (the caller fetches them).
|
|
131
|
+
*/
|
|
132
|
+
export async function classifyMergeTree(runCommand, repoDir, childHead, requiredEpicSha) {
|
|
133
|
+
for (const sha of [childHead, requiredEpicSha]) {
|
|
134
|
+
if (!OID_RE.test(sha)) {
|
|
135
|
+
return { kind: "operational_error", reason: "a merge input is not a full commit SHA" };
|
|
136
|
+
}
|
|
137
|
+
if (!(await commitPresent(runCommand, repoDir, sha))) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "operational_error",
|
|
140
|
+
reason: `commit ${sha.slice(0, 12)} is not present in the local object database`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
result = await runCommand("git", ["merge-tree", "--write-tree", "-z", "--name-only", "--messages", childHead, requiredEpicSha], { cwd: repoDir, timeoutMs: MERGE_TREE_COMMAND_TIMEOUT_MS });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { kind: "operational_error", reason: "git merge-tree could not be run" };
|
|
150
|
+
}
|
|
151
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
152
|
+
return { kind: "operational_error", reason: `git merge-tree exited ${result.exitCode}` };
|
|
153
|
+
}
|
|
154
|
+
const parsed = parseMergeTreeOutput(result.stdout);
|
|
155
|
+
if (parsed === null) {
|
|
156
|
+
return { kind: "operational_error", reason: "git merge-tree produced no tree object" };
|
|
157
|
+
}
|
|
158
|
+
if (result.exitCode === 0) {
|
|
159
|
+
return { kind: "clean", treeOid: parsed.treeOid };
|
|
160
|
+
}
|
|
161
|
+
if (parsed.conflicts.length === 0) {
|
|
162
|
+
// Exit 1 WITH a tree but no conflict record: git said "not clean" and did not
|
|
163
|
+
// say why. Unknown is conflicted, never clean.
|
|
164
|
+
return {
|
|
165
|
+
kind: "conflicted",
|
|
166
|
+
treeOid: parsed.treeOid,
|
|
167
|
+
conflicts: [{ path: "(unreported)", descriptor: "CONFLICT (unknown)", messages: [] }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { kind: "conflicted", treeOid: parsed.treeOid, conflicts: parsed.conflicts };
|
|
171
|
+
}
|