@bridge_gpt/mcp-server 0.2.66 → 0.2.67
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 +252 -77
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +43 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/commands.generated.js +11 -11
- package/build/conductor/bridge-api-client.js +143 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-implementer-cli.js +22 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +18 -2
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +181 -29
- package/build/conductor/recovery-cli.js +200 -8
- package/build/conductor/recovery-operations.js +69 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/docs.generated.js +8 -3
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +85 -54
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +22 -20
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/env.js +19 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/job-errors.js +25 -0
- package/build/executor/job-runner.js +225 -41
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +51 -14
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +8 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/worker-finalization.js +82 -29
- package/build/executor/worker-guard-hook.js +137 -33
- package/build/executor/worktree.js +82 -1
- package/build/index.js +403 -429
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +3 -3
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +18 -24
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +37 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/setup-epic.js +44 -19
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +179 -31
- package/build/tool-error-envelope.js +61 -0
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +3 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +130 -4
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -34,9 +34,14 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
|
|
|
34
34
|
/* degraded: omit last_commit_sha */
|
|
35
35
|
}
|
|
36
36
|
try {
|
|
37
|
+
// `--no-optional-locks`: a plain `git status` refreshes the index and takes
|
|
38
|
+
// `.git/index.lock`. This runs on every heartbeat against the LIVE worker's
|
|
39
|
+
// worktree, so without it a worker's own `git add`/`commit` can fail with
|
|
40
|
+
// "index.lock: File exists".
|
|
37
41
|
const status = await deps.runCommand("git", [
|
|
38
42
|
"-C",
|
|
39
43
|
worktreePath,
|
|
44
|
+
"--no-optional-locks",
|
|
40
45
|
"status",
|
|
41
46
|
"--porcelain",
|
|
42
47
|
]);
|
|
@@ -293,7 +298,7 @@ const RATE_LIMIT_CONSEQUENCE_TEXT = "This warning is advisory only and triggers
|
|
|
293
298
|
"rejection and the server advertised capacity recovery for this job; otherwise " +
|
|
294
299
|
"the failure follows ordinary failure handling.";
|
|
295
300
|
/** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
|
|
296
|
-
const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/
|
|
301
|
+
const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/epic-implementer/operator-runbook.md §8.";
|
|
297
302
|
/**
|
|
298
303
|
* Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
|
|
299
304
|
*
|
|
@@ -2,7 +2,8 @@ import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
|
|
|
2
2
|
import { inspectExecutorAgentAdapter, inspectionForFailedResolution, } from "../agent-launchers/executor-adapter-inspection.js";
|
|
3
3
|
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
4
4
|
import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
|
|
5
|
-
import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, runtimeMatchesSupported, } from "./completion-contract.js";
|
|
5
|
+
import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, cliVersionAtLeast, normalizeCliVersion, parseCliVersion, runtimeMatchesSupported, } from "./completion-contract.js";
|
|
6
|
+
import { buildHeadlessAgentEnv } from "./env.js";
|
|
6
7
|
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
7
8
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
@@ -123,20 +124,34 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
|
|
|
123
124
|
if (probed === null) {
|
|
124
125
|
return { qualified: false, refusal: "runtime identity probe did not run", runtime: null };
|
|
125
126
|
}
|
|
126
|
-
const declared = capability.value.
|
|
127
|
+
const declared = capability.value.predicate;
|
|
127
128
|
const mismatches = [];
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
if (probed.adapter !== declared.adapter) {
|
|
130
|
+
mismatches.push(`adapter ${String(probed.adapter).slice(0, 40)} != ${declared.adapter}`);
|
|
131
|
+
}
|
|
132
|
+
if (probed.event_schema !== declared.event_schema) {
|
|
133
|
+
mismatches.push(`event_schema ${String(probed.event_schema).slice(0, 40)} != ${declared.event_schema}`);
|
|
132
134
|
}
|
|
133
135
|
if (mismatches.length > 0) {
|
|
134
136
|
return {
|
|
135
137
|
qualified: false,
|
|
136
|
-
refusal: bounded(`probed runtime differs from the
|
|
138
|
+
refusal: bounded(`probed runtime differs from the declared predicate: ${mismatches.join("; ")}`),
|
|
137
139
|
runtime: probed,
|
|
138
140
|
};
|
|
139
141
|
}
|
|
142
|
+
// Fixed reasons below: the probed version and platform are never echoed, because
|
|
143
|
+
// an unparseable value is arbitrary probe output.
|
|
144
|
+
const version = parseCliVersion(probed.cli_version);
|
|
145
|
+
const minimum = parseCliVersion(declared.min_cli_version);
|
|
146
|
+
if (version === null || minimum === null) {
|
|
147
|
+
return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
|
|
148
|
+
}
|
|
149
|
+
if (!cliVersionAtLeast(version, minimum)) {
|
|
150
|
+
return { qualified: false, refusal: "probed CLI version is older than the minimum", runtime: probed };
|
|
151
|
+
}
|
|
152
|
+
if (!declared.platforms.includes(probed.platform)) {
|
|
153
|
+
return { qualified: false, refusal: "probed platform is not a supported platform", runtime: probed };
|
|
154
|
+
}
|
|
140
155
|
if (!runtimeMatchesSupported(probed)) {
|
|
141
156
|
return {
|
|
142
157
|
qualified: false,
|
|
@@ -144,7 +159,12 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
|
|
|
144
159
|
runtime: probed,
|
|
145
160
|
};
|
|
146
161
|
}
|
|
147
|
-
|
|
162
|
+
// Normalized (suffix stripped) so the retained identity and the envelope carry one form.
|
|
163
|
+
const normalized = normalizeCliVersion(probed.cli_version);
|
|
164
|
+
if (normalized === null) {
|
|
165
|
+
return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
|
|
166
|
+
}
|
|
167
|
+
return { qualified: true, refusal: null, runtime: { ...probed, cli_version: normalized } };
|
|
148
168
|
}
|
|
149
169
|
/** Fail-fast report shape for a preflight that refused before probing. */
|
|
150
170
|
function refusedPreflight(fatalFindings, warnings, adapter) {
|
|
@@ -160,23 +180,29 @@ function refusedPreflight(fatalFindings, warnings, adapter) {
|
|
|
160
180
|
adapter,
|
|
161
181
|
};
|
|
162
182
|
}
|
|
163
|
-
const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-
|
|
183
|
+
export const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v2";
|
|
184
|
+
const spawnCorrectionHook = (args, input, env) => {
|
|
185
|
+
const result = spawnSync(process.execPath, args, { input, encoding: "utf8", env, timeout: 5000 });
|
|
186
|
+
return { status: result.status, ...(result.error ? { error: result.error } : {}) };
|
|
187
|
+
};
|
|
164
188
|
/** Exercise the packaged hook process, including a nested-agent entry attempt. */
|
|
165
|
-
export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js")) {
|
|
189
|
+
export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js"), run = spawnCorrectionHook) {
|
|
166
190
|
const args = [binPath, "--worktree-root", worktreePath,
|
|
167
191
|
"--correction-intent-version", "1"];
|
|
168
192
|
const env = { ...process.env, BAPI_CORRECTION_INTENT_VERSION: "1" };
|
|
169
193
|
const fixtures = [
|
|
170
194
|
{ payload: { tool_name: "Skill", tool_input: { skill: "bridge-implement-ticket" } }, status: 2 },
|
|
171
195
|
{ payload: { tool_name: "Task", tool_input: { prompt: "/implement-ticket BAPI-1 --auto" } }, status: 2 },
|
|
172
|
-
{ payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status:
|
|
196
|
+
{ payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 0 },
|
|
197
|
+
{
|
|
198
|
+
payload: { tool_name: "Bash", tool_input: { command: 'claude -p "/implement-ticket BAPI-1"' } },
|
|
199
|
+
status: 2,
|
|
200
|
+
},
|
|
173
201
|
{ payload: { tool_name: "mcp__bridge_api__request_plan_generation", tool_input: {} }, status: 2 },
|
|
174
202
|
{ payload: { tool_name: "mcp__bridge_api__get_plan", tool_input: { ticket_number: "BAPI-1" } }, status: 0 },
|
|
175
203
|
];
|
|
176
204
|
return fixtures.every(({ payload, status }) => {
|
|
177
|
-
const result =
|
|
178
|
-
input: JSON.stringify(payload), encoding: "utf8", env, timeout: 5000,
|
|
179
|
-
});
|
|
205
|
+
const result = run(args, JSON.stringify(payload), env);
|
|
180
206
|
return result.error === undefined && result.status === status;
|
|
181
207
|
});
|
|
182
208
|
}
|
|
@@ -411,8 +437,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
411
437
|
if (denyCapability?.supported !== true) {
|
|
412
438
|
throw new Error("adapter declares no deny-enforcement capability");
|
|
413
439
|
}
|
|
440
|
+
// The probe gets a worker-shaped environment so a nested-session variable in
|
|
441
|
+
// the executor's own process cannot change its verdict (BAPI-1386).
|
|
414
442
|
const verdict = await denyCapability.value.probeEnforcement({
|
|
415
443
|
timeoutMs: resolveDenyProbeTimeoutMs(deps.env),
|
|
444
|
+
env: buildHeadlessAgentEnv(deps.env),
|
|
416
445
|
});
|
|
417
446
|
deny = {
|
|
418
447
|
enforced: verdict.enforced,
|
|
@@ -486,6 +515,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
486
515
|
if (!completion.qualified && completion.refusal !== null) {
|
|
487
516
|
warnings.push(`completion evidence not advertised: ${completion.refusal}`);
|
|
488
517
|
}
|
|
518
|
+
// One notice per qualifying collection, built only from validated fields: the
|
|
519
|
+
// adapter id the predicate matched, the normalized version, and a closed platform id.
|
|
520
|
+
const notices = [];
|
|
521
|
+
if (completion.qualified && completion.runtime !== null) {
|
|
522
|
+
notices.push(`completion evidence advertised: ${completion.runtime.adapter} ` +
|
|
523
|
+
`${completion.runtime.cli_version} ${completion.runtime.platform}`);
|
|
524
|
+
}
|
|
489
525
|
let correction = {
|
|
490
526
|
qualified: false, evidence: null,
|
|
491
527
|
};
|
|
@@ -504,6 +540,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
504
540
|
ok: fatalFindings.length === 0,
|
|
505
541
|
fatalFindings,
|
|
506
542
|
warnings,
|
|
543
|
+
notices,
|
|
507
544
|
agentVersion,
|
|
508
545
|
wtVersion,
|
|
509
546
|
diskFreeGb,
|
|
@@ -877,6 +877,10 @@ export function createRequiredWorkLedger(init) {
|
|
|
877
877
|
const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
|
|
878
878
|
return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
|
|
879
879
|
};
|
|
880
|
+
// One predicate for "a required task is still pending", shared by the envelope
|
|
881
|
+
// and the observe-only query so the two cannot drift apart.
|
|
882
|
+
const hasPendingRequiredWork = () => effectiveTasks().some(({ o, state }) => o.required && state === "pending");
|
|
883
|
+
const observeLiveRequiredWork = () => hasPendingRequiredWork() ? "live_required_work" : null;
|
|
880
884
|
const buildEnvelope = (facts) => {
|
|
881
885
|
const reasons = new Set(insufficient);
|
|
882
886
|
if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
|
|
@@ -897,7 +901,7 @@ export function createRequiredWorkLedger(init) {
|
|
|
897
901
|
reasons.add("forced_kill_no_cli_teardown");
|
|
898
902
|
const effective = effectiveTasks();
|
|
899
903
|
// R6: a required task with no terminal after the stream ended is missing evidence.
|
|
900
|
-
if (
|
|
904
|
+
if (hasPendingRequiredWork())
|
|
901
905
|
reasons.add("live_required_work");
|
|
902
906
|
// R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
|
|
903
907
|
const snap = liveSnapshotIds();
|
|
@@ -993,6 +997,7 @@ export function createRequiredWorkLedger(init) {
|
|
|
993
997
|
recordDeclarationRefused: () => {
|
|
994
998
|
insufficient.add("declaration_refused");
|
|
995
999
|
},
|
|
1000
|
+
observeLiveRequiredWork,
|
|
996
1001
|
buildEnvelope,
|
|
997
1002
|
};
|
|
998
1003
|
}
|
|
@@ -149,6 +149,8 @@ export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
|
|
|
149
149
|
// ---------------------------------------------------------------------------
|
|
150
150
|
/** The deterministic WIP checkpoint message for a continuation. */
|
|
151
151
|
export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
|
|
152
|
+
/** BAPI-1367: the WIP checkpoint message when a retry resumes its prior attempt's work. */
|
|
153
|
+
export const PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before retry resume";
|
|
152
154
|
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
153
155
|
/** Read the continuation binding from a claimed job's payload (pure). */
|
|
154
156
|
export function readContinuationBinding(job) {
|
package/build/executor/runner.js
CHANGED
|
@@ -70,7 +70,7 @@ export function formatDispatcherUnavailableDiagnostic(liveness) {
|
|
|
70
70
|
return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
|
|
71
71
|
`(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
|
|
72
72
|
"Check DISABLE_SCHEDULER and GET /automation/health; see " +
|
|
73
|
-
"docs/
|
|
73
|
+
"docs/epic-implementer/operator-runbook.md. Continuing to poll.");
|
|
74
74
|
}
|
|
75
75
|
/** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
|
|
76
76
|
export function formatDispatcherRecoveredDiagnostic() {
|
|
@@ -94,7 +94,8 @@ export const PROCESS_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
|
94
94
|
/** Delivery-failure diagnostic. Bounded, credential-free, stderr-only. */
|
|
95
95
|
export function formatProcessHeartbeatFailureDiagnostic() {
|
|
96
96
|
return ("executor: process-heartbeat delivery is failing; the server may report this " +
|
|
97
|
-
"executor as stale.
|
|
97
|
+
"executor as stale. If the server is unreachable, job heartbeats and claims " +
|
|
98
|
+
"can fail too, and a running job can lose its lease.");
|
|
98
99
|
}
|
|
99
100
|
/** Emitted once when delivery starts working again. */
|
|
100
101
|
export function formatProcessHeartbeatRecoveredDiagnostic() {
|
|
@@ -819,6 +820,11 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
819
820
|
for (const warning of report.warnings) {
|
|
820
821
|
deps.errorLog(`executor preflight warning: ${warning}`);
|
|
821
822
|
}
|
|
823
|
+
// BAPI-1386: informational lines (the completion-evidence advertisement) go to
|
|
824
|
+
// stderr like every other diagnostic here, never stdout.
|
|
825
|
+
for (const notice of report.notices ?? []) {
|
|
826
|
+
deps.errorLog(`executor preflight: ${notice}`);
|
|
827
|
+
}
|
|
822
828
|
if (!report.ok) {
|
|
823
829
|
deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
|
|
824
830
|
}
|
|
@@ -929,7 +929,8 @@ export function resolvePackagedExecutorInvocation(deps) {
|
|
|
929
929
|
/** User-facing usage for `executor install-service`. */
|
|
930
930
|
export function getExecutorInstallServiceUsage() {
|
|
931
931
|
return [
|
|
932
|
-
"Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...]
|
|
932
|
+
"Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...]",
|
|
933
|
+
" (--epic-run-id <id> | --repo-wide) [options]",
|
|
933
934
|
"",
|
|
934
935
|
"The FILE-GENERATION LEG of durable-host mode. It writes one unit and stops there.",
|
|
935
936
|
"For the supported composed bring-up — planning, consented write, consented start,",
|
|
@@ -947,6 +948,11 @@ export function getExecutorInstallServiceUsage() {
|
|
|
947
948
|
" needs a durable identity, so the foreground",
|
|
948
949
|
" <hostname>-<pid> default is never used here.",
|
|
949
950
|
" --repo <name> Repo to serve (repeatable; --repos=a,b also works).",
|
|
951
|
+
" --epic-run-id <id> Claim scope: dedicate the unit to an epic run",
|
|
952
|
+
" (repeatable). Or:",
|
|
953
|
+
" --repo-wide Claim scope: claim from every authorized repo,",
|
|
954
|
+
" unscoped by run. Exactly one of the two is required;",
|
|
955
|
+
" the executor refuses to start without one.",
|
|
950
956
|
" --base-url <url> Bridge API endpoint, or set BAPI_BASE_URL.",
|
|
951
957
|
" There is NO production default.",
|
|
952
958
|
"",
|
|
@@ -60,6 +60,15 @@
|
|
|
60
60
|
* reports its head, publication is `unknown`
|
|
61
61
|
* (`WorkerFinalizationPublicationUnverified`), never `ok`.
|
|
62
62
|
*
|
|
63
|
+
* BAPI-1357: "absent" and "could not be checked" are different facts. Only the
|
|
64
|
+
* origin lookup's authoritative "no matching ref" (`git ls-remote --exit-code`
|
|
65
|
+
* exit code 2), or having no expected branch at all, is reported as
|
|
66
|
+
* `WorkerFinalizationMissingRemoteBranchAndPr`; a thrown command, an expired
|
|
67
|
+
* call bound, any other exit, or unusable output leaves publication unknown and is
|
|
68
|
+
* `WorkerFinalizationOriginUnverifiable`, which the server treats as transient.
|
|
69
|
+
* Both keep the `clean_exit` classification the process earned. No-expected-branch
|
|
70
|
+
* and absent-on-origin are still one cause, and may be refined later.
|
|
71
|
+
*
|
|
63
72
|
* This check is deliberately fail-CLOSED, unlike the BAPI-586 base check it now
|
|
64
73
|
* shares a lookup with. "The PR could not be verified" and "there is no PR" are
|
|
65
74
|
* the same thing from the contract's point of view: neither is evidence that the
|
|
@@ -67,7 +76,7 @@
|
|
|
67
76
|
* this ticket removes. The reconciler stays the decision-maker — the executor
|
|
68
77
|
* only reports the observation (R14 rule 3).
|
|
69
78
|
*/
|
|
70
|
-
import { ReconcileOverlapPushUnfinalized,
|
|
79
|
+
import { ReconcileOverlapPushUnfinalized, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationOriginUnverifiable, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationPublicationUnverified, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
|
|
71
80
|
import { isImplementationStyleJobType } from "./job-types.js";
|
|
72
81
|
/** A full, lower-case 40-hex commit SHA. */
|
|
73
82
|
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
@@ -129,40 +138,56 @@ function parseLsRemoteHeadSha(stdout, expectedRef) {
|
|
|
129
138
|
}
|
|
130
139
|
return null;
|
|
131
140
|
}
|
|
141
|
+
/** `git ls-remote --exit-code` exits with this status when no ref matched. */
|
|
142
|
+
const LS_REMOTE_NO_MATCHING_REF_EXIT_CODE = 2;
|
|
132
143
|
/**
|
|
133
|
-
* Resolve the origin branch's authoritative current tip
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
144
|
+
* Resolve the origin branch's authoritative current tip. Uses `git ls-remote`
|
|
145
|
+
* against the exact normalized ref — never a local `origin/<branch>`
|
|
146
|
+
* remote-tracking ref, which can be stale on a recovery worktree.
|
|
147
|
+
*
|
|
148
|
+
* Only exit code 2 (`--exit-code`'s "no matching ref") is `absent`. A thrown
|
|
149
|
+
* command, an expired call bound, every other non-zero exit, and an exit-zero
|
|
150
|
+
* response with no usable SHA for the exact ref are all `unverifiable`: none of
|
|
151
|
+
* them is evidence the branch is missing. Neither stderr nor a thrown error's
|
|
152
|
+
* message is read or copied anywhere.
|
|
138
153
|
*/
|
|
139
154
|
export async function resolveOriginBranchSha(runCommand, worktreePath, branch, bound) {
|
|
140
155
|
const normalizedRef = normalizeBranchRef(branch);
|
|
141
156
|
if (callExpired(bound))
|
|
142
|
-
return
|
|
143
|
-
|
|
157
|
+
return { state: "unverifiable" };
|
|
158
|
+
let result;
|
|
159
|
+
try {
|
|
160
|
+
result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], callOptions(worktreePath, bound));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { state: "unverifiable" };
|
|
164
|
+
}
|
|
165
|
+
if (result.exitCode === LS_REMOTE_NO_MATCHING_REF_EXIT_CODE)
|
|
166
|
+
return { state: "absent" };
|
|
144
167
|
if (result.exitCode !== 0)
|
|
145
|
-
return
|
|
146
|
-
|
|
168
|
+
return { state: "unverifiable" };
|
|
169
|
+
const sha = parseLsRemoteHeadSha(result.stdout, normalizedRef);
|
|
170
|
+
return sha === null ? { state: "unverifiable" } : { state: "found", sha };
|
|
147
171
|
}
|
|
148
172
|
/**
|
|
149
173
|
* Repeatedly resolve the authoritative origin tip until it either satisfies
|
|
150
174
|
* finalization or the bounded retry budget is exhausted. Stops early (no further
|
|
151
175
|
* lookups) when the origin tip matches the worker HEAD, or when the branch exists
|
|
152
176
|
* and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
177
|
+
* an observed absence, a found tip that does not yet match a known HEAD, and an
|
|
178
|
+
* unverifiable read (a later read may succeed). Returns the LAST observation with
|
|
179
|
+
* its state intact — an unverifiable read is never turned into an absence here,
|
|
180
|
+
* so the caller decides loud-failure from what was actually established.
|
|
156
181
|
*/
|
|
157
182
|
async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound) {
|
|
158
183
|
const total = Math.max(1, attempts);
|
|
159
|
-
let
|
|
184
|
+
let lookup = { state: "unverifiable" };
|
|
160
185
|
for (let attempt = 0; attempt < total; attempt++) {
|
|
161
|
-
|
|
162
|
-
if (
|
|
186
|
+
lookup = await resolveOriginBranchSha(runCommand, worktreePath, branch, bound?.());
|
|
187
|
+
if (lookup.state === "found") {
|
|
163
188
|
// Existence-only success when HEAD is unknown; exact-match success otherwise.
|
|
164
|
-
if (!trimmedHeadSha ||
|
|
165
|
-
return {
|
|
189
|
+
if (!trimmedHeadSha || lookup.sha === trimmedHeadSha) {
|
|
190
|
+
return { lookup, attemptsUsed: attempt + 1 };
|
|
166
191
|
}
|
|
167
192
|
}
|
|
168
193
|
if (attempt < total - 1)
|
|
@@ -170,7 +195,7 @@ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, b
|
|
|
170
195
|
}
|
|
171
196
|
// `attemptsUsed` is reported even on failure so the caller can tell a
|
|
172
197
|
// first-try answer from one that needed the settling window (BAPI-862).
|
|
173
|
-
return {
|
|
198
|
+
return { lookup, attemptsUsed: total };
|
|
174
199
|
}
|
|
175
200
|
/** The bounded, public projection finalization asks the provider for. */
|
|
176
201
|
const PR_VIEW_JSON_FIELDS = "number,url,baseRefName,body,headRefOid";
|
|
@@ -385,12 +410,19 @@ function publicationUnverifiedFailure(job, branch, workerHeadSha, detail) {
|
|
|
385
410
|
};
|
|
386
411
|
}
|
|
387
412
|
/**
|
|
388
|
-
* `detail` describes a state with NO evidence any work reached origin
|
|
389
|
-
*
|
|
413
|
+
* `detail` describes a state with NO evidence any work reached origin, and is
|
|
414
|
+
* used for exactly two cases: no expected branch to verify, or a branch the
|
|
415
|
+
* origin lookup OBSERVED absent (exit code 2). An origin lookup that merely
|
|
416
|
+
* failed is {@link originUnverifiableFailure}, never this. Those two cases stay
|
|
417
|
+
* one cause for now and may be refined later.
|
|
390
418
|
* `hasPrUrl` only changes the wording — a PR existing is never treated as
|
|
391
419
|
* evidence of a push, so the message must not claim "no PR URL" when one was
|
|
392
420
|
* actually present (that would be false and would mislead the operator reading
|
|
393
421
|
* it toward the wrong recovery step).
|
|
422
|
+
*
|
|
423
|
+
* The classification is `clean_exit` because the worker process did exit
|
|
424
|
+
* cleanly; the finalization failure rides in the error kind, not in a rewritten
|
|
425
|
+
* exit (the same rule the other finalization failures follow).
|
|
394
426
|
*/
|
|
395
427
|
function missingBranchAndPrFailure(job, detail, hasPrUrl) {
|
|
396
428
|
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
@@ -398,7 +430,23 @@ function missingBranchAndPrFailure(job, detail, hasPrUrl) {
|
|
|
398
430
|
return {
|
|
399
431
|
error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
|
|
400
432
|
error_message: `${label} exited cleanly but ${prefix} ${detail}`,
|
|
401
|
-
classification: "
|
|
433
|
+
classification: "clean_exit",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* BAPI-1357: the origin lookup could not be trusted, so the branch was neither
|
|
438
|
+
* seen nor observed missing. The wording is fixed and bounded: it names only the
|
|
439
|
+
* ticket, job and branch (public identifiers), never a command, stderr, a thrown
|
|
440
|
+
* error's text, a host, or a credential, and it does not claim the branch is
|
|
441
|
+
* absent. The server treats this kind as transient.
|
|
442
|
+
*/
|
|
443
|
+
function originUnverifiableFailure(job, branch) {
|
|
444
|
+
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
445
|
+
return {
|
|
446
|
+
error_kind: WorkerFinalizationOriginUnverifiable,
|
|
447
|
+
error_message: `${label} exited cleanly, but whether branch '${branch}' reached origin could not be ` +
|
|
448
|
+
`verified. The branch was not observed to be missing; publication is unknown for this attempt.`,
|
|
449
|
+
classification: "clean_exit",
|
|
402
450
|
};
|
|
403
451
|
}
|
|
404
452
|
/**
|
|
@@ -525,19 +573,24 @@ export async function validateWorkerFinalization(input) {
|
|
|
525
573
|
try {
|
|
526
574
|
origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound);
|
|
527
575
|
}
|
|
528
|
-
catch
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
576
|
+
catch {
|
|
577
|
+
// BAPI-1357: nothing thrown here says anything about the branch, and its text
|
|
578
|
+
// is never rendered — the fixed unverifiable outcome is the whole report.
|
|
579
|
+
return { ok: false, failure: originUnverifiableFailure(job, trimmedBranch) };
|
|
580
|
+
}
|
|
581
|
+
// BAPI-1357: only an authoritative "no matching ref" is an absence. A lookup
|
|
582
|
+
// that could not be trusted stays unknown and never funds the deterministic
|
|
583
|
+
// missing-branch failure.
|
|
584
|
+
if (origin.lookup.state === "unverifiable") {
|
|
585
|
+
return { ok: false, failure: originUnverifiableFailure(job, trimmedBranch) };
|
|
533
586
|
}
|
|
534
|
-
|
|
535
|
-
if (remoteSha === null) {
|
|
587
|
+
if (origin.lookup.state === "absent") {
|
|
536
588
|
return {
|
|
537
589
|
ok: false,
|
|
538
590
|
failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' is not on origin`, hasPrUrl),
|
|
539
591
|
};
|
|
540
592
|
}
|
|
593
|
+
const remoteSha = origin.lookup.sha;
|
|
541
594
|
// BAPI-762: an authoritative origin tip that does not match the worker's HEAD
|
|
542
595
|
// is positive evidence of RECOVERABLE work — distinct from the sub-cases above,
|
|
543
596
|
// which have no evidence any work reached origin at all. This is the only
|