@bridge_gpt/mcp-server 0.2.62 → 0.2.66
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 +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +146 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- package/pipelines/implement-ticket.json +1 -1
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ad-hoc, READ-ONLY attachment to a running (or recently-run) job's worker log:
|
|
5
5
|
* it resolves the local job-log registry record, verifies the log file exists,
|
|
6
|
-
* and `tail -f`s it with inherited stdio.
|
|
6
|
+
* and `tail -f`s it with inherited stdio.
|
|
7
|
+
*
|
|
8
|
+
* BAPI-1330: each attempt has its own stable log file. The newest attempt is
|
|
9
|
+
* watched by default and `--attempt <n>` selects an earlier one. A reference the
|
|
10
|
+
* registry cannot vouch for — a legacy fixed-alias record, an unknown attempt,
|
|
11
|
+
* a transcript that was never recorded — is reported by its named reason; the
|
|
12
|
+
* watch never follows a guessed or shared file. It NEVER sends a signal to the worker
|
|
7
13
|
* process and never owns the execution lifecycle — closing the watch leaves the
|
|
8
14
|
* worker untouched. Missing registry records or missing log files fail loud with
|
|
9
15
|
* actionable stderr text and a non-zero exit code.
|
|
@@ -15,7 +21,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
|
15
21
|
import { readExecutorJobLogRecord, } from "./job-log-registry.js";
|
|
16
22
|
export function getExecutorWatchUsage() {
|
|
17
23
|
return [
|
|
18
|
-
"Usage: mcp-server executor watch <job-id>",
|
|
24
|
+
"Usage: mcp-server executor watch <job-id> [--attempt <n>]",
|
|
19
25
|
"",
|
|
20
26
|
"Attach read-only to a running (or recently-run) executor job's worker log",
|
|
21
27
|
"via `tail -f`. Closing the watch never affects the worker process.",
|
|
@@ -24,15 +30,32 @@ export function getExecutorWatchUsage() {
|
|
|
24
30
|
" <job-id> Numeric executor job id to watch.",
|
|
25
31
|
"",
|
|
26
32
|
"Options:",
|
|
33
|
+
" --attempt <n> Watch that attempt's log instead of the newest one.",
|
|
27
34
|
" -h, --help Show this help.",
|
|
28
35
|
].join("\n");
|
|
29
36
|
}
|
|
30
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Accept EXACTLY one numeric job id plus an optional `--attempt <n>` (or
|
|
39
|
+
* `--attempt=<n>`); reject missing/non-numeric/extra args.
|
|
40
|
+
*/
|
|
31
41
|
export function parseExecutorWatchArgs(argv) {
|
|
32
42
|
const positionals = [];
|
|
33
|
-
|
|
43
|
+
let attemptRaw;
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const arg = argv[i];
|
|
34
46
|
if (arg === "-h" || arg === "--help")
|
|
35
47
|
return { kind: "help" };
|
|
48
|
+
if (arg === "--attempt" || arg.startsWith("--attempt=")) {
|
|
49
|
+
if (attemptRaw !== undefined) {
|
|
50
|
+
return { kind: "error", message: "--attempt may be given only once" };
|
|
51
|
+
}
|
|
52
|
+
const value = arg === "--attempt" ? argv[++i] : arg.slice("--attempt=".length);
|
|
53
|
+
if (value === undefined || !/^[0-9]+$/.test(value)) {
|
|
54
|
+
return { kind: "error", message: "--attempt requires a non-negative attempt number" };
|
|
55
|
+
}
|
|
56
|
+
attemptRaw = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
36
59
|
if (arg.startsWith("-"))
|
|
37
60
|
return { kind: "error", message: `unknown argument: ${arg}` };
|
|
38
61
|
positionals.push(arg);
|
|
@@ -47,7 +70,9 @@ export function parseExecutorWatchArgs(argv) {
|
|
|
47
70
|
if (!/^[0-9]+$/.test(raw)) {
|
|
48
71
|
return { kind: "error", message: `job id must be numeric: '${raw}'` };
|
|
49
72
|
}
|
|
50
|
-
return
|
|
73
|
+
return attemptRaw === undefined
|
|
74
|
+
? { kind: "ok", jobId: Number(raw) }
|
|
75
|
+
: { kind: "ok", jobId: Number(raw), attempt: Number(attemptRaw) };
|
|
51
76
|
}
|
|
52
77
|
/**
|
|
53
78
|
* Spawn `tail -f <log_path>` with inherited stdio for interactive CLI attachment.
|
|
@@ -98,14 +123,16 @@ export async function runExecutorWatchCli(argv, overrides = {}) {
|
|
|
98
123
|
}
|
|
99
124
|
const registryDeps = overrides.registryDeps ?? createDefaultWatchRegistryDeps();
|
|
100
125
|
const readRecord = overrides.readRecord ?? readExecutorJobLogRecord;
|
|
101
|
-
const
|
|
126
|
+
const selector = parsed.attempt === undefined ? {} : { attempt: parsed.attempt };
|
|
127
|
+
const record = await readRecord(parsed.jobId, registryDeps, selector);
|
|
102
128
|
if (!record.ok) {
|
|
103
|
-
errorLog(`Error:
|
|
129
|
+
errorLog(`Error: worker log for job ${parsed.jobId} is unavailable (${record.code}): ${record.reason}`);
|
|
104
130
|
return 1;
|
|
105
131
|
}
|
|
106
132
|
const logExists = overrides.logExists ?? defaultLogExists;
|
|
107
133
|
if (!(await logExists(record.record.log_path))) {
|
|
108
|
-
errorLog(`Error: worker log for job ${parsed.jobId}
|
|
134
|
+
errorLog(`Error: worker log for job ${parsed.jobId} attempt ${record.record.attempt} is unavailable ` +
|
|
135
|
+
`(log_file_missing): expected at ${record.record.log_path}`);
|
|
109
136
|
return 1;
|
|
110
137
|
}
|
|
111
138
|
const spawnTail = overrides.spawnTail ?? ((logPath) => spawnTailFollow(logPath));
|
|
@@ -17,13 +17,19 @@
|
|
|
17
17
|
* distinct from the argv vocabulary is what stops a future flag rename in one
|
|
18
18
|
* agent's CLI from becoming an operator-visible policy break across all of them.
|
|
19
19
|
*/
|
|
20
|
-
import { ExecutorNamedError } from "./job-errors.js";
|
|
21
|
-
import {
|
|
20
|
+
import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
|
|
21
|
+
import { isRemainingWork, validateCorrectionIntent } from "./correction-dispatch.js";
|
|
22
|
+
/**
|
|
23
|
+
* BAPI-1296: `ci_fix` is the one recovery job with a FOCUSED prompt route. It
|
|
24
|
+
* renders its server-authored `payload.prompt_spec` (the dispatched failing and
|
|
25
|
+
* missing checks) in `job-runner.ts`, and is never given the full recipe.
|
|
26
|
+
*/
|
|
27
|
+
export const CI_FIX_JOB_TYPE = "ci_fix";
|
|
22
28
|
/**
|
|
23
29
|
* Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
|
|
24
|
-
*
|
|
25
|
-
* `implement`
|
|
26
|
-
*
|
|
30
|
+
* Remaining work first requires a validated bounded correction intent. Only an
|
|
31
|
+
* initial `implement` job may synthesize `/implement-ticket <ticket_key> --auto`.
|
|
32
|
+
* `ci_fix` uses the focused prompt-spec route after pre-worktree validation.
|
|
27
33
|
*
|
|
28
34
|
* Structured `payload.prompt_spec` rendering is NOT done here: it needs the
|
|
29
35
|
* prepared worktree/git state, so `job-runner.ts` renders it in the prepared-spawn
|
|
@@ -38,46 +44,76 @@ import { isRecoveryJobType } from "./job-types.js";
|
|
|
38
44
|
* explicit prompt. It no longer depends on the prompt-spec fall-through.
|
|
39
45
|
*/
|
|
40
46
|
/**
|
|
41
|
-
*
|
|
42
|
-
* explicit `payload.prompt` is present: the first-pass `implement` plus the
|
|
43
|
-
* recovery jobs (`remediate`/`ci_fix`/`rebase`), which re-enter the SAME
|
|
44
|
-
* correction loop on the ticket's existing branch/PR. Recovery classification is
|
|
45
|
-
* shared with dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`)
|
|
46
|
-
* so eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
|
|
47
|
-
* they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
|
|
47
|
+
* Only the first-pass `implement` job synthesizes the complete workflow.
|
|
48
48
|
*/
|
|
49
|
-
function synthesizesImplementPrompt(jobType) {
|
|
50
|
-
return jobType === "implement"
|
|
49
|
+
export function synthesizesImplementPrompt(jobType) {
|
|
50
|
+
return jobType === "implement";
|
|
51
51
|
}
|
|
52
52
|
export function resolveExecutorPrompt(job) {
|
|
53
53
|
const payload = job.payload && typeof job.payload === "object"
|
|
54
54
|
? job.payload
|
|
55
55
|
: {};
|
|
56
|
+
if (job.job_type !== CI_FIX_JOB_TYPE && isRemainingWork(job.job_type, payload)) {
|
|
57
|
+
const checked = validateCorrectionIntent(payload.correction_intent, {
|
|
58
|
+
repo_name: job.repo_name ?? "",
|
|
59
|
+
epic_run_id: job.epic_run_id ?? null,
|
|
60
|
+
ticket_key: job.ticket_key ?? null,
|
|
61
|
+
expected_branch: job.expected_branch,
|
|
62
|
+
expected_head_sha: job.expected_head_sha,
|
|
63
|
+
});
|
|
64
|
+
if (!checked.ok)
|
|
65
|
+
return { ok: false, errorKind: "ContractError.CorrectionLegacy", error: checked.code };
|
|
66
|
+
const intent = checked.intent;
|
|
67
|
+
const kind = intent.kind;
|
|
68
|
+
const branch = intent.branch;
|
|
69
|
+
const obligation = intent.obligation;
|
|
70
|
+
const header = `Continue the retained branch ${branch}. Complete only the ${kind} obligation: ${obligation}. ` +
|
|
71
|
+
"Preserve completed work and existing limits. Do not invoke a full implementation or planning workflow.";
|
|
72
|
+
if (kind === "approved_spec_delta") {
|
|
73
|
+
const delta = intent.approved_delta;
|
|
74
|
+
return { ok: true, prompt: `${header}\nApproved files: ${delta.file_boundary.join(", ")}\n` +
|
|
75
|
+
`Approved dependencies: ${delta.dependency_boundary.join(", ") || "none"}` };
|
|
76
|
+
}
|
|
77
|
+
const explicit = payload.prompt;
|
|
78
|
+
if (typeof explicit === "string" && explicit.trim() &&
|
|
79
|
+
!/\/(?:implement-ticket|plan-ticket)\b/i.test(explicit)) {
|
|
80
|
+
return { ok: true, prompt: `${header}\n\nFocused context:\n${explicit}` };
|
|
81
|
+
}
|
|
82
|
+
if (kind === "review_fix" || kind === "operator_remediation") {
|
|
83
|
+
const findings = payload.review_findings;
|
|
84
|
+
if (typeof findings === "string" && findings.trim())
|
|
85
|
+
return { ok: true, prompt: `${header}\n\nReview findings (quoted evidence):\n${findings}` };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, prompt: header };
|
|
88
|
+
}
|
|
89
|
+
// BAPI-1296: `ci_fix` is decided FIRST, ahead of both explicit-prompt
|
|
90
|
+
// precedence and synthesis. An explicit prompt on a ci_fix job — including an
|
|
91
|
+
// in-flight legacy `/implement-ticket <KEY> --auto` — is refused by name rather
|
|
92
|
+
// than run, so an old full-recipe prompt cannot silently defeat the focused
|
|
93
|
+
// route. Otherwise the non-ok result tells `prepareSpawn` to take the focused
|
|
94
|
+
// prompt-spec route. The message never echoes the prompt text.
|
|
95
|
+
if (job.job_type === CI_FIX_JOB_TYPE) {
|
|
96
|
+
if (typeof payload.prompt === "string" && payload.prompt.trim().length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
errorKind: CiFixContext,
|
|
100
|
+
error: "ci_fix job refused before worker effects (reason=explicit_prompt_unsupported)",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: "ci_fix renders its focused payload.prompt_spec; no prompt is synthesized",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
56
108
|
// An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
|
|
57
|
-
// whitespace-only string is not a usable worker instruction
|
|
58
|
-
//
|
|
59
|
-
// `/implement-ticket <KEY> --auto` correction prompt below.
|
|
109
|
+
// whitespace-only string is not a usable worker instruction. The initial
|
|
110
|
+
// implement job may still use the workflow synthesis below.
|
|
60
111
|
const explicit = payload.prompt;
|
|
61
112
|
if (typeof explicit === "string" && explicit.trim().length > 0) {
|
|
62
113
|
return { ok: true, prompt: explicit };
|
|
63
114
|
}
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// change addressing, and merge-conflict resolution (see .claude/commands/
|
|
67
|
-
// implement-ticket.md "Clean session exit"). So the recovery jobs that operate
|
|
68
|
-
// on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
|
|
69
|
-
// by RE-ENTERING THE COMPLETE RECIPE from step 1, not a distinct resume path.
|
|
70
|
-
// That re-entry is safe (BAPI-746), backed by re-entry handling already
|
|
71
|
-
// present in three of the recipe's own steps, not a new assumption: plan
|
|
72
|
-
// generation is idempotent because the server reuses a fresh existing plan
|
|
73
|
-
// instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
|
|
74
|
-
// treats a clean tree with an already-pushed branch/PR as a legitimate
|
|
75
|
-
// re-entry rather than an empty implementation; and create-pr.md treats an
|
|
76
|
-
// already-open pull request on the head branch as satisfying its success
|
|
77
|
-
// condition. Without synthesizing this command, a code_review
|
|
78
|
-
// `changes_requested` (near-certain in real runs) enqueues a `remediate` job
|
|
79
|
-
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
|
|
80
|
-
// #3).
|
|
115
|
+
// This final synthesis route is for initial feature work only. The remaining
|
|
116
|
+
// work branch returned a focused prompt or a named refusal above.
|
|
81
117
|
const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
82
118
|
? job.ticket_key.trim()
|
|
83
119
|
: null;
|
|
@@ -45,9 +45,20 @@
|
|
|
45
45
|
* so an omitted body opens a PR with no description at all), and — when the
|
|
46
46
|
* provider reports it — sitting at the same pushed head. Anything short of that
|
|
47
47
|
* is `WorkerFinalizationPrNotAttached`, whose guidance names attachment and never
|
|
48
|
-
* suggests removing or re-writing the branch: the commits
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* suggests removing or re-writing the branch: the commits are durable pushed
|
|
49
|
+
* work, and both of the stale-worktree guard's historical remedies would have
|
|
50
|
+
* destroyed them.
|
|
51
|
+
*
|
|
52
|
+
* BAPI-1330: publication is not completion. A pushed head and an attached pull
|
|
53
|
+
* request prove the work was PUBLISHED; they say nothing about whether the
|
|
54
|
+
* required work is finished or whether any check passed, so no message here
|
|
55
|
+
* claims either. A clean exit that fails finalization keeps the `clean_exit`
|
|
56
|
+
* classification the process actually earned — the finalization failure rides
|
|
57
|
+
* separately as the error kind. And a COVERED attempt (one the server enrolled
|
|
58
|
+
* in the completion-evidence contract) may not use the existence-only fallback:
|
|
59
|
+
* without a full worker HEAD, a matching origin tip and a pull request that
|
|
60
|
+
* reports its head, publication is `unknown`
|
|
61
|
+
* (`WorkerFinalizationPublicationUnverified`), never `ok`.
|
|
51
62
|
*
|
|
52
63
|
* This check is deliberately fail-CLOSED, unlike the BAPI-586 base check it now
|
|
53
64
|
* shares a lookup with. "The PR could not be verified" and "there is no PR" are
|
|
@@ -56,8 +67,20 @@
|
|
|
56
67
|
* this ticket removes. The reconciler stays the decision-maker — the executor
|
|
57
68
|
* only reports the observation (R14 rule 3).
|
|
58
69
|
*/
|
|
59
|
-
import { ReconcileOverlapPushUnfinalized, secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
|
|
70
|
+
import { ReconcileOverlapPushUnfinalized, secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationPublicationUnverified, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
|
|
60
71
|
import { isImplementationStyleJobType } from "./job-types.js";
|
|
72
|
+
/** A full, lower-case 40-hex commit SHA. */
|
|
73
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
74
|
+
/** The `runCommand` options for one bounded read; `{ cwd }` alone when unbounded. */
|
|
75
|
+
function callOptions(worktreePath, bound) {
|
|
76
|
+
return bound && typeof bound.timeoutMs === "number"
|
|
77
|
+
? { cwd: worktreePath, timeoutMs: Math.max(1, Math.floor(bound.timeoutMs)) }
|
|
78
|
+
: { cwd: worktreePath };
|
|
79
|
+
}
|
|
80
|
+
/** True when a bounded call has no time left and must not be issued at all. */
|
|
81
|
+
function callExpired(bound) {
|
|
82
|
+
return bound !== undefined && typeof bound.timeoutMs === "number" && bound.timeoutMs <= 0;
|
|
83
|
+
}
|
|
61
84
|
/** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
|
|
62
85
|
const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
|
|
63
86
|
const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
|
|
@@ -113,9 +136,11 @@ function parseLsRemoteHeadSha(stdout, expectedRef) {
|
|
|
113
136
|
* on a recovery worktree. A non-zero exit is treated as "branch not found"
|
|
114
137
|
* without copying raw stderr into diagnostics.
|
|
115
138
|
*/
|
|
116
|
-
export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
|
|
139
|
+
export async function resolveOriginBranchSha(runCommand, worktreePath, branch, bound) {
|
|
117
140
|
const normalizedRef = normalizeBranchRef(branch);
|
|
118
|
-
|
|
141
|
+
if (callExpired(bound))
|
|
142
|
+
return null;
|
|
143
|
+
const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], callOptions(worktreePath, bound));
|
|
119
144
|
if (result.exitCode !== 0)
|
|
120
145
|
return null;
|
|
121
146
|
return parseLsRemoteHeadSha(result.stdout, normalizedRef);
|
|
@@ -129,11 +154,11 @@ export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
|
|
|
129
154
|
* yet match. Returns the LAST authoritative observation — the caller decides
|
|
130
155
|
* loud-failure from that final value, so true negatives are preserved.
|
|
131
156
|
*/
|
|
132
|
-
async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
|
|
157
|
+
async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound) {
|
|
133
158
|
const total = Math.max(1, attempts);
|
|
134
159
|
let remoteSha = null;
|
|
135
160
|
for (let attempt = 0; attempt < total; attempt++) {
|
|
136
|
-
remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
|
|
161
|
+
remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch, bound?.());
|
|
137
162
|
if (remoteSha !== null) {
|
|
138
163
|
// Existence-only success when HEAD is unknown; exact-match success otherwise.
|
|
139
164
|
if (!trimmedHeadSha || remoteSha === trimmedHeadSha) {
|
|
@@ -170,14 +195,16 @@ const PR_LOOKUP_ABSENT = {
|
|
|
170
195
|
* collapses to {@link PR_LOOKUP_ABSENT} without copying raw stderr, an
|
|
171
196
|
* authenticated remote, or the command line into anything the caller can render.
|
|
172
197
|
*/
|
|
173
|
-
async function resolvePrForFinalization(runCommand, worktreePath, branch) {
|
|
198
|
+
async function resolvePrForFinalization(runCommand, worktreePath, branch, bound) {
|
|
174
199
|
const args = ["pr", "view"];
|
|
175
200
|
if (branch)
|
|
176
201
|
args.push(branch);
|
|
177
202
|
args.push("--json", PR_VIEW_JSON_FIELDS);
|
|
203
|
+
if (callExpired(bound))
|
|
204
|
+
return PR_LOOKUP_ABSENT;
|
|
178
205
|
let result;
|
|
179
206
|
try {
|
|
180
|
-
result = await runCommand("gh", args,
|
|
207
|
+
result = await runCommand("gh", args, callOptions(worktreePath, bound));
|
|
181
208
|
}
|
|
182
209
|
catch {
|
|
183
210
|
return PR_LOOKUP_ABSENT;
|
|
@@ -254,7 +281,7 @@ function evaluatePrBase(job, pr, expectedBase) {
|
|
|
254
281
|
* means the provider has not yet reflected the push, not that the push is in
|
|
255
282
|
* doubt.
|
|
256
283
|
*/
|
|
257
|
-
function evaluatePrContract(pr, workerHeadSha) {
|
|
284
|
+
function evaluatePrContract(pr, workerHeadSha, coveredAttempt = false) {
|
|
258
285
|
if (!pr.found) {
|
|
259
286
|
return {
|
|
260
287
|
ok: false,
|
|
@@ -282,6 +309,17 @@ function evaluatePrContract(pr, workerHeadSha) {
|
|
|
282
309
|
retryable: false,
|
|
283
310
|
};
|
|
284
311
|
}
|
|
312
|
+
if (coveredAttempt && pr.headRefOid === null) {
|
|
313
|
+
// BAPI-1330: a legacy attempt accepts a PR whose head the provider did not
|
|
314
|
+
// report; a covered one cannot, because the PR head is part of what binds
|
|
315
|
+
// the published work to this attempt's final head.
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
detail: "its pull request reports no head commit",
|
|
319
|
+
retryable: true,
|
|
320
|
+
unverified: true,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
285
323
|
if (workerHeadSha && pr.headRefOid !== null && pr.headRefOid !== workerHeadSha) {
|
|
286
324
|
return {
|
|
287
325
|
ok: false,
|
|
@@ -303,9 +341,16 @@ function evaluatePrContract(pr, workerHeadSha) {
|
|
|
303
341
|
* so the executor failure and the reconciler's observation are searchable as
|
|
304
342
|
* one state instead of two vocabularies for the same thing.
|
|
305
343
|
* 2. It recommends attachment and says plainly that removing or re-writing the
|
|
306
|
-
* branch destroys
|
|
307
|
-
*
|
|
308
|
-
*
|
|
344
|
+
* branch destroys pushed work. In the F4 incident the pushed branch held the
|
|
345
|
+
* implementation, and both remedies the stale-worktree guard offered would
|
|
346
|
+
* have thrown it away.
|
|
347
|
+
*
|
|
348
|
+
* BAPI-1330: it claims ONLY what finalization observed — durable pushed commits
|
|
349
|
+
* and an unmet pull-request attachment. Whether the required work is finished,
|
|
350
|
+
* and whether any check passed, is stated as not established here; only the
|
|
351
|
+
* completion evidence (when the attempt was covered) can speak to either. The
|
|
352
|
+
* classification is `clean_exit`, because that is how the process ended; the
|
|
353
|
+
* finalization failure is carried by the error kind, not by rewriting the exit.
|
|
309
354
|
*/
|
|
310
355
|
function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail) {
|
|
311
356
|
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
@@ -314,14 +359,31 @@ function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail)
|
|
|
314
359
|
return {
|
|
315
360
|
error_kind: WorkerFinalizationPrNotAttached,
|
|
316
361
|
error_message: `${label} exited cleanly and its HEAD${headLabel} is durable on origin branch '${branch}', ` +
|
|
317
|
-
`but ${detail}. This is the pr_not_attached state: the
|
|
318
|
-
`
|
|
319
|
-
`
|
|
320
|
-
`
|
|
321
|
-
|
|
362
|
+
`but ${detail}. This is the pr_not_attached state: the commits are pushed and the pull ` +
|
|
363
|
+
`request attachment is unmet. Whether the required work is finished, and whether any ` +
|
|
364
|
+
`check passed, is not established by this observation. Recovery: open or attach a pull ` +
|
|
365
|
+
`request for '${branch}'${baseLabel} with a non-empty description. Removing or re-writing ` +
|
|
366
|
+
`that branch would destroy pushed work.`,
|
|
367
|
+
classification: "clean_exit",
|
|
322
368
|
last_commit_sha: workerHeadSha || undefined,
|
|
323
369
|
};
|
|
324
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* BAPI-1330: a covered attempt whose publication could not be PROVEN. Built only
|
|
373
|
+
* from public identifiers and a fixed detail phrase. It never says a pull request
|
|
374
|
+
* is absent, because none was observed to be — the verdict is `unknown`.
|
|
375
|
+
*/
|
|
376
|
+
function publicationUnverifiedFailure(job, branch, workerHeadSha, detail) {
|
|
377
|
+
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
378
|
+
return {
|
|
379
|
+
error_kind: WorkerFinalizationPublicationUnverified,
|
|
380
|
+
error_message: `${label} exited cleanly, but publication of branch '${branch}' could not be verified: ` +
|
|
381
|
+
`${detail}. Publication, required-work completion and check status are unknown for this ` +
|
|
382
|
+
`attempt; nothing was observed to be missing and nothing on the branch was changed.`,
|
|
383
|
+
classification: "clean_exit",
|
|
384
|
+
last_commit_sha: FULL_SHA_RE.test(workerHeadSha) ? workerHeadSha : undefined,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
325
387
|
/**
|
|
326
388
|
* `detail` describes a state with NO evidence any work reached origin (branch
|
|
327
389
|
* unresolved, branch absent from origin, or the origin lookup itself failed).
|
|
@@ -398,6 +460,8 @@ export async function validateWorkerFinalization(input) {
|
|
|
398
460
|
if (!isImplementationStyleJobType(job.job_type)) {
|
|
399
461
|
return { ok: true };
|
|
400
462
|
}
|
|
463
|
+
// BAPI-1311: resolved fresh for every call so each read gets only the time left.
|
|
464
|
+
const bound = () => input.callTimeoutMs ? { timeoutMs: input.callTimeoutMs() } : undefined;
|
|
401
465
|
const hasPrUrl = Boolean(extractPrUrl(result));
|
|
402
466
|
// BAPI-762: `branch`/`headSha` are normalized once here and reused through the
|
|
403
467
|
// rest of the check — including by the diagnostics below — instead of being
|
|
@@ -418,7 +482,7 @@ export async function validateWorkerFinalization(input) {
|
|
|
418
482
|
// unconditional. Their dispositions still differ on purpose (see `PrLookup`),
|
|
419
483
|
// and the ORDER is unchanged: a wrong-base PR is still reported before any
|
|
420
484
|
// origin lookup, exactly as BAPI-586 specified.
|
|
421
|
-
let pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
|
|
485
|
+
let pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
|
|
422
486
|
// BAPI-586: a wrong-base PR (opened against a dependency's feature branch or the
|
|
423
487
|
// repo default instead of the run base) never fires `claude-review.yml` and
|
|
424
488
|
// strands the ticket at `code_review`. When the run base is known, fail loud on
|
|
@@ -443,12 +507,23 @@ export async function validateWorkerFinalization(input) {
|
|
|
443
507
|
// runs unconditionally — including when `hasPrUrl` is true (BAPI-762, AC-5): a
|
|
444
508
|
// PR being open is never accepted as proof this worker's own commit landed.
|
|
445
509
|
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
|
|
510
|
+
const covered = input.coveredAttempt === true;
|
|
511
|
+
// BAPI-1330: the existence-only fallback below is a LEGACY concession to
|
|
512
|
+
// degraded telemetry. A covered attempt cannot bind its final head to anything
|
|
513
|
+
// without the worker's full HEAD, so it stops here with an `unknown` verdict
|
|
514
|
+
// rather than accepting "the branch exists" as publication.
|
|
515
|
+
if (covered && !FULL_SHA_RE.test(trimmedHeadSha)) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
failure: publicationUnverifiedFailure(job, trimmedBranch, trimmedHeadSha, "the worker's full HEAD commit could not be established"),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
446
521
|
const attempts = normalizeAttempts(input.originResolveAttempts);
|
|
447
522
|
const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
|
|
448
523
|
const sleep = input.sleep ?? sleepMs;
|
|
449
524
|
let origin;
|
|
450
525
|
try {
|
|
451
|
-
origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
|
|
526
|
+
origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound);
|
|
452
527
|
}
|
|
453
528
|
catch (err) {
|
|
454
529
|
return {
|
|
@@ -497,16 +572,22 @@ export async function validateWorkerFinalization(input) {
|
|
|
497
572
|
// base check fail-opened on it, and the PR that has since appeared must not
|
|
498
573
|
// reach success without that check.
|
|
499
574
|
if (origin.attemptsUsed > 1) {
|
|
500
|
-
pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
|
|
575
|
+
pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
|
|
501
576
|
const refreshedBaseVerdict = evaluatePrBase(job, pr, expectedBase);
|
|
502
577
|
if (refreshedBaseVerdict)
|
|
503
578
|
return { ok: false, failure: refreshedBaseVerdict };
|
|
504
579
|
}
|
|
505
|
-
let contract = evaluatePrContract(pr, trimmedHeadSha);
|
|
580
|
+
let contract = evaluatePrContract(pr, trimmedHeadSha, covered);
|
|
506
581
|
for (let attempt = 1; attempt < attempts && !contract.ok && contract.retryable; attempt++) {
|
|
507
582
|
await sleep(retryDelayMs);
|
|
508
|
-
pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
|
|
509
|
-
contract = evaluatePrContract(pr, trimmedHeadSha);
|
|
583
|
+
pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
|
|
584
|
+
contract = evaluatePrContract(pr, trimmedHeadSha, covered);
|
|
585
|
+
}
|
|
586
|
+
if (!contract.ok && contract.unverified === true) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
failure: publicationUnverifiedFailure(job, trimmedBranch, trimmedHeadSha, contract.detail),
|
|
590
|
+
};
|
|
510
591
|
}
|
|
511
592
|
if (!contract.ok) {
|
|
512
593
|
return {
|
|
@@ -514,5 +595,66 @@ export async function validateWorkerFinalization(input) {
|
|
|
514
595
|
failure: prNotAttachedFailure(job, trimmedBranch, trimmedHeadSha, expectedBase, contract.detail),
|
|
515
596
|
};
|
|
516
597
|
}
|
|
517
|
-
return {
|
|
598
|
+
return {
|
|
599
|
+
ok: true,
|
|
600
|
+
prUrl: pr.url ?? undefined,
|
|
601
|
+
prNumber: pr.prNumber,
|
|
602
|
+
// BAPI-1311: the verified origin tip — proven equal to the worker's HEAD above
|
|
603
|
+
// whenever that HEAD was known. Only for a caller that asked for it.
|
|
604
|
+
...(input.exposePushedHead === true ? { pushedHeadSha: remoteSha } : {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// BAPI-1311 — the focused rebase wait's pull-request read
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
/**
|
|
611
|
+
* The bounded public projection the focused rebase wait asks for. Separate from
|
|
612
|
+
* {@link PR_VIEW_JSON_FIELDS} on purpose: legacy finalization keeps its exact
|
|
613
|
+
* field selection, and only the rebase wait pays for state and mergeability.
|
|
614
|
+
*/
|
|
615
|
+
const FOCUSED_PR_VIEW_JSON_FIELDS = "number,state,headRefOid,headRefName,baseRefName,mergeable";
|
|
616
|
+
/**
|
|
617
|
+
* Read the bound pull request's current state with ONE bounded `gh pr view`.
|
|
618
|
+
*
|
|
619
|
+
* Addresses the pull request by NUMBER, never by branch, so a second pull request
|
|
620
|
+
* opened for the same branch can never be mistaken for the bound one. Parses only
|
|
621
|
+
* {@link FOCUSED_PR_VIEW_JSON_FIELDS}; raw stdout/stderr never leave this
|
|
622
|
+
* function. An already-expired bound issues no command at all.
|
|
623
|
+
*/
|
|
624
|
+
export async function readFocusedPullRequest(runCommand, worktreePath, prNumber, bound) {
|
|
625
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0 || callExpired(bound)) {
|
|
626
|
+
return { available: false };
|
|
627
|
+
}
|
|
628
|
+
let result;
|
|
629
|
+
try {
|
|
630
|
+
result = await runCommand("gh", ["pr", "view", String(prNumber), "--json", FOCUSED_PR_VIEW_JSON_FIELDS], callOptions(worktreePath, bound));
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return { available: false };
|
|
634
|
+
}
|
|
635
|
+
if (result.exitCode !== 0)
|
|
636
|
+
return { available: false };
|
|
637
|
+
let parsed;
|
|
638
|
+
try {
|
|
639
|
+
parsed = JSON.parse(result.stdout);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return { available: false };
|
|
643
|
+
}
|
|
644
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
645
|
+
return { available: false };
|
|
646
|
+
const obj = parsed;
|
|
647
|
+
const rawState = nonBlankString(obj.state);
|
|
648
|
+
const rawHead = nonBlankString(obj.headRefOid);
|
|
649
|
+
const rawMergeable = nonBlankString(obj.mergeable);
|
|
650
|
+
const mergeable = rawMergeable === "MERGEABLE" ? "mergeable" : rawMergeable === "CONFLICTING" ? "conflicting" : "unknown";
|
|
651
|
+
return {
|
|
652
|
+
available: true,
|
|
653
|
+
number: typeof obj.number === "number" && Number.isInteger(obj.number) ? obj.number : null,
|
|
654
|
+
state: rawState === null ? null : rawState.toUpperCase(),
|
|
655
|
+
headRefOid: rawHead === null ? null : rawHead.toLowerCase(),
|
|
656
|
+
headRefName: nonBlankString(obj.headRefName),
|
|
657
|
+
baseRefName: nonBlankString(obj.baseRefName),
|
|
658
|
+
mergeable,
|
|
659
|
+
};
|
|
518
660
|
}
|