@bridge_gpt/mcp-server 0.2.51 → 0.2.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +130 -28
- package/build/executor/merge-job.js +67 -16
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +514 -121
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +5 -3
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +560 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +3 -2
package/build/setup-epic.js
CHANGED
|
@@ -30,9 +30,15 @@
|
|
|
30
30
|
* file-overlap serialization, so a divergence is legitimate, not corruption.
|
|
31
31
|
* 5. Store, then approve.
|
|
32
32
|
*
|
|
33
|
+
* BAPI-1009 — the epic branch is the DEFAULT for a multi-ticket epic. A plan
|
|
34
|
+
* with two or more nodes and no branch flag derives `epic/<KEY>` on EVERY path
|
|
35
|
+
* (piped, CI, and `--json` included), so the scope-bearing sequence below is now
|
|
36
|
+
* the ordinary path rather than an opt-in one. A single-node plan stays on the
|
|
37
|
+
* repository base branch, and `--into-base` forces base for any node count.
|
|
38
|
+
*
|
|
33
39
|
* BAPI-850 — a FEATURE-BRANCH run (`policy_json.base_branch` set, whether by
|
|
34
|
-
* `--feature-branch
|
|
35
|
-
* start sequence in this one invocation, in this order:
|
|
40
|
+
* the BAPI-1009 default, `--feature-branch`, or `--policy-file`) performs the
|
|
41
|
+
* full scope-bearing start sequence in this one invocation, in this order:
|
|
36
42
|
*
|
|
37
43
|
* a. Read canonical parse truth (`GET /jira/parse-status`). No succeeded
|
|
38
44
|
* parse or no indexed commit → exit 1 with "parse the repository first";
|
|
@@ -47,13 +53,18 @@
|
|
|
47
53
|
* d. Store and approve (unchanged requests). The server now READ-validates the
|
|
48
54
|
* ref against the scope's recorded cut and schedules the seed + verifying
|
|
49
55
|
* parse in the background; the run goes `active` immediately.
|
|
50
|
-
* e. Poll the scope's lifecycle with a bounded wait
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
56
|
+
* e. Poll the scope's lifecycle with a SHORT bounded wait (BAPI-1054:
|
|
57
|
+
* {@link SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS} polls, ~30s), reporting
|
|
58
|
+
* `Provisioning → Seeding → Verifying → Ready`. A scope still preparing at
|
|
59
|
+
* that deadline is acknowledged as `created, cut deferred` and exits 0 —
|
|
60
|
+
* the run is created and approved, and preparation continues server-side.
|
|
61
|
+
* Only `failed` and `ready_mismatch` are terminal and exit 1. The
|
|
62
|
+
* reconciler's dispatch-freshness hold keeps an un-ready scope from
|
|
63
|
+
* dispatching in every case, which is what makes the deferred return safe.
|
|
54
64
|
*
|
|
55
|
-
* A run with no feature branch
|
|
56
|
-
* `git`, no scope, no polling, no
|
|
65
|
+
* A run with no feature branch — a single-node plan, or `--into-base` — is
|
|
66
|
+
* byte-identical to before: no parse read, no `git`, no scope, no polling, no
|
|
67
|
+
* extra JSON keys.
|
|
57
68
|
*
|
|
58
69
|
* Reads the plan sidecar and the optional policy file from the local
|
|
59
70
|
* filesystem. As of BAPI-872 it can also WRITE the local filesystem in one
|
|
@@ -66,9 +77,9 @@
|
|
|
66
77
|
*/
|
|
67
78
|
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
68
79
|
import os from "node:os";
|
|
69
|
-
import readline from "node:readline";
|
|
70
80
|
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
|
|
71
81
|
import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
82
|
+
import { resolveTrackedPaths } from "./git-reachability.js";
|
|
72
83
|
import { validateBranchName } from "./base-ref.js";
|
|
73
84
|
// Reused from the conduct-epic client on purpose: adding a second wrapper under
|
|
74
85
|
// `conductor/` would give the v2 path its own copy to drift from.
|
|
@@ -79,7 +90,7 @@ import { validateBranchName } from "./base-ref.js";
|
|
|
79
90
|
// conductor can drift on a field name or an endpoint shape. No MCP tool, no
|
|
80
91
|
// input-schema field, and no description changes.
|
|
81
92
|
import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./conduct-epic/bridge-client.js";
|
|
82
|
-
import { createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_LIFECYCLE_LABELS, } from "./conduct-epic/cut-protocol.js";
|
|
93
|
+
import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./conduct-epic/cut-protocol.js";
|
|
83
94
|
import { hashPlan } from "./conductor/plan.js";
|
|
84
95
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
85
96
|
// BAPI-872: local plane-manifest binding, so `plane down` can later stop the
|
|
@@ -93,25 +104,6 @@ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
|
|
|
93
104
|
"native_review_decision",
|
|
94
105
|
"none",
|
|
95
106
|
];
|
|
96
|
-
/** Echoed single-line prompt on stderr (mirrors connect-github's helper). */
|
|
97
|
-
function defaultPromptLine(promptText) {
|
|
98
|
-
return new Promise((resolve) => {
|
|
99
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
100
|
-
// `rl.close()` emits `close` SYNCHRONOUSLY, so without this guard the close
|
|
101
|
-
// handler would settle the promise empty and discard a real answer. EOF must
|
|
102
|
-
// also resolve rather than deadlock a top-level await.
|
|
103
|
-
let answered = false;
|
|
104
|
-
rl.on("close", () => {
|
|
105
|
-
if (!answered)
|
|
106
|
-
resolve("");
|
|
107
|
-
});
|
|
108
|
-
rl.question(promptText, (answer) => {
|
|
109
|
-
answered = true;
|
|
110
|
-
rl.close();
|
|
111
|
-
resolve(answer.trim());
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
107
|
export function createDefaultSetupEpicDeps() {
|
|
116
108
|
return {
|
|
117
109
|
env: process.env,
|
|
@@ -123,8 +115,6 @@ export function createDefaultSetupEpicDeps() {
|
|
|
123
115
|
fetch: globalThis.fetch,
|
|
124
116
|
log: (m) => console.log(m),
|
|
125
117
|
errorLog: (m) => console.error(m),
|
|
126
|
-
isTTY: Boolean(process.stdin.isTTY),
|
|
127
|
-
promptLine: defaultPromptLine,
|
|
128
118
|
runCommand: createExecFileRunCommand(),
|
|
129
119
|
epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
|
|
130
120
|
planeBinding: {
|
|
@@ -142,6 +132,12 @@ export function getSetupEpicUsage() {
|
|
|
142
132
|
"Bootstraps an Epic Conductor v2 run: creates the run, stores the plan DAG,",
|
|
143
133
|
"and approves it. Idempotent — re-running reuses an existing live run.",
|
|
144
134
|
"",
|
|
135
|
+
"Branch strategy (default): a plan with TWO OR MORE nodes runs on a dedicated",
|
|
136
|
+
"epic branch named epic/<KEY>, derived automatically on every path (piped, CI,",
|
|
137
|
+
"and --json included). Every child-ticket PR targets epic/<KEY>, and one DRAFT",
|
|
138
|
+
"integration PR targets the repository base branch. A single-node plan keeps the",
|
|
139
|
+
"repository base branch, matching plain start-tickets semantics.",
|
|
140
|
+
"",
|
|
145
141
|
"Required:",
|
|
146
142
|
" --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
|
|
147
143
|
" --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
|
|
@@ -149,15 +145,26 @@ export function getSetupEpicUsage() {
|
|
|
149
145
|
"Options:",
|
|
150
146
|
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
151
147
|
" --plan-version <n> Assert the sidecar's plan_version equals <n>",
|
|
152
|
-
" --feature-branch <name>
|
|
148
|
+
" --feature-branch <name> OVERRIDE the derived epic/<KEY> name with your own.",
|
|
153
149
|
" setup-epic cuts the branch on origin with your LOCAL git",
|
|
154
150
|
" at exactly the repository's canonical indexed commit",
|
|
155
151
|
" (the repository must be parsed first), the server",
|
|
156
152
|
" validates it, and every child-ticket PR targets it.",
|
|
157
|
-
" setup-epic then waits (
|
|
158
|
-
|
|
159
|
-
"
|
|
160
|
-
|
|
153
|
+
" setup-epic then waits BRIEFLY (about " +
|
|
154
|
+
`${SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS}s) for the run's index scope to`,
|
|
155
|
+
" become ready. If it is still preparing at that point the",
|
|
156
|
+
` command exits 0 with a '${SETUP_EPIC_CUT_DEFERRED_PHRASE}'`,
|
|
157
|
+
" acknowledgement naming the run and scope: the run IS",
|
|
158
|
+
" created and approved, preparation continues server-side,",
|
|
159
|
+
" and the reconciler holds ticket dispatch until it is",
|
|
160
|
+
" ready. Only a recorded scope failure exits non-zero.",
|
|
161
|
+
" Also forces an epic branch for a SINGLE-node plan, which",
|
|
162
|
+
" the default would leave on base.",
|
|
163
|
+
" --into-base OPT OUT of the epic-branch default: cut no branch and",
|
|
164
|
+
" target every child PR at the repository base branch",
|
|
165
|
+
" (the pre-BAPI-1009 behavior). Use this if automation",
|
|
166
|
+
" depended on \"no flag = direct to base\". Contradictory",
|
|
167
|
+
" with --feature-branch; passing both is a parse error.",
|
|
161
168
|
" --review-policy <src> PER-RUN review policy source, one of:",
|
|
162
169
|
` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
|
|
163
170
|
" Composed into policy_json.review_policy on create.",
|
|
@@ -256,6 +263,12 @@ export function parseSetupEpicArgs(argv) {
|
|
|
256
263
|
let repo;
|
|
257
264
|
let planVersion;
|
|
258
265
|
let featureBranch;
|
|
266
|
+
// BAPI-1009: tracked SEPARATELY from `featureBranch`, because an explicitly
|
|
267
|
+
// blank `--feature-branch=` normalizes the value to `undefined` while still
|
|
268
|
+
// being a supplied branch-strategy intent. The contradiction guard below must
|
|
269
|
+
// fire on the INTENT, not on whether a name survived normalization.
|
|
270
|
+
let featureBranchSupplied = false;
|
|
271
|
+
let intoBase = false;
|
|
259
272
|
let reviewPolicy;
|
|
260
273
|
let policyFile;
|
|
261
274
|
let replacePolicy = false;
|
|
@@ -278,6 +291,7 @@ export function parseSetupEpicArgs(argv) {
|
|
|
278
291
|
if (!parsedFb.ok)
|
|
279
292
|
return { status: "error", message: parsedFb.error };
|
|
280
293
|
featureBranch = parsedFb.value;
|
|
294
|
+
featureBranchSupplied = true;
|
|
281
295
|
continue;
|
|
282
296
|
}
|
|
283
297
|
// `--review-policy=<source>` inline form (BAPI-679).
|
|
@@ -298,6 +312,7 @@ export function parseSetupEpicArgs(argv) {
|
|
|
298
312
|
if (!parsedFb.ok)
|
|
299
313
|
return { status: "error", message: parsedFb.error };
|
|
300
314
|
featureBranch = parsedFb.value;
|
|
315
|
+
featureBranchSupplied = true;
|
|
301
316
|
i++;
|
|
302
317
|
break;
|
|
303
318
|
}
|
|
@@ -361,6 +376,9 @@ export function parseSetupEpicArgs(argv) {
|
|
|
361
376
|
i++;
|
|
362
377
|
break;
|
|
363
378
|
}
|
|
379
|
+
case "--into-base":
|
|
380
|
+
intoBase = true;
|
|
381
|
+
break;
|
|
364
382
|
case "--replace-policy":
|
|
365
383
|
replacePolicy = true;
|
|
366
384
|
break;
|
|
@@ -395,6 +413,18 @@ export function parseSetupEpicArgs(argv) {
|
|
|
395
413
|
"on a real setup.",
|
|
396
414
|
};
|
|
397
415
|
}
|
|
416
|
+
// BAPI-1009: the two branch-strategy flags are contradictory intents, refused
|
|
417
|
+
// HERE alongside the other pre-flight contradictions — before any file read,
|
|
418
|
+
// credential resolution, git command, or network request.
|
|
419
|
+
if (featureBranchSupplied && intoBase) {
|
|
420
|
+
return {
|
|
421
|
+
status: "error",
|
|
422
|
+
message: "--feature-branch and --into-base select contradictory branch strategies: " +
|
|
423
|
+
"--feature-branch runs the epic on a named epic branch, --into-base runs " +
|
|
424
|
+
"children directly into the repository base branch. Only one branch strategy " +
|
|
425
|
+
"may be selected — remove whichever one you did not mean.",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
398
428
|
if (replacePolicy && policyFile === undefined) {
|
|
399
429
|
return {
|
|
400
430
|
status: "error",
|
|
@@ -411,6 +441,7 @@ export function parseSetupEpicArgs(argv) {
|
|
|
411
441
|
repo,
|
|
412
442
|
planVersion,
|
|
413
443
|
featureBranch,
|
|
444
|
+
intoBase,
|
|
414
445
|
reviewPolicy,
|
|
415
446
|
policyFile,
|
|
416
447
|
replacePolicy,
|
|
@@ -524,10 +555,16 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
|
|
|
524
555
|
};
|
|
525
556
|
}
|
|
526
557
|
if (fileReview.kind === "source" && fileReview.value !== selections.reviewPolicy) {
|
|
558
|
+
// BAPI-1054: the file's value is NOT echoed. `review_policy.source` is an
|
|
559
|
+
// arbitrary string on the wire, read straight out of an operator-authored
|
|
560
|
+
// file that can also carry `notify.webhook_url` — so interpolating it put
|
|
561
|
+
// unbounded file-derived text into stderr and CI logs. Naming the field and
|
|
562
|
+
// the CLI selection (which came from a closed vocabulary and is therefore
|
|
563
|
+
// safe) says everything the operator needs to fix it.
|
|
527
564
|
return {
|
|
528
565
|
ok: false,
|
|
529
566
|
error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file ` +
|
|
530
|
-
`declares review_policy.source
|
|
567
|
+
`declares a different review_policy.source. setup-epic will not pick ` +
|
|
531
568
|
`a winner between them: drop the flag, or fix the policy file so both agree.`,
|
|
532
569
|
};
|
|
533
570
|
}
|
|
@@ -541,6 +578,53 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
|
|
|
541
578
|
}
|
|
542
579
|
return { ok: true, policy: composed };
|
|
543
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* The single named policy gate, run before any credential resolution or request.
|
|
583
|
+
*
|
|
584
|
+
* Pure, and deterministic in its refusal order: it reports the FIRST violation
|
|
585
|
+
* and stops, so one invocation yields one named reason. It consolidates checks
|
|
586
|
+
* that already existed but were scattered across two call sites, which is what
|
|
587
|
+
* let `--local-only --dry-run` and an ordinary run evaluate slightly different
|
|
588
|
+
* sets — a dry run is worth nothing if it does not refuse what the real run will.
|
|
589
|
+
*
|
|
590
|
+
* It is READ-ONLY with respect to policy content: nothing here normalizes,
|
|
591
|
+
* coerces, deletes, or reconstructs a policy file's keys.
|
|
592
|
+
* {@link composeSetupEpicPolicy} remains the only function that adds validated
|
|
593
|
+
* CLI selections, and it is called from here so the object that passes the gate
|
|
594
|
+
* is exactly the object the request later carries.
|
|
595
|
+
*
|
|
596
|
+
* Every message is secret-free by construction: fixed prose, the flag names, the
|
|
597
|
+
* file PATH, and CLI-supplied values that came from a closed vocabulary. No
|
|
598
|
+
* message interpolates a value read out of the policy file.
|
|
599
|
+
*/
|
|
600
|
+
export function preflightSetupEpicPolicy(args) {
|
|
601
|
+
// 1. Branch strategy. `--feature-branch` versus `--into-base` is already a
|
|
602
|
+
// parse error, refused before a file is even read; this is the same
|
|
603
|
+
// contradiction spelled across two sources instead of two flags.
|
|
604
|
+
if (args.intoBase && args.policyDeclaresBranch) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
reason: "branch_strategy_contradiction",
|
|
608
|
+
message: `--into-base runs children directly into the repository base branch, but ` +
|
|
609
|
+
`--policy-file '${args.policyFile}' declares a base_branch, which asks for an ` +
|
|
610
|
+
`epic branch. setup-epic will not pick a winner between them: remove the ` +
|
|
611
|
+
`branch declaration from the policy file, or drop --into-base.`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
// 2. Policy-source contradictions between the file and the CLI selections.
|
|
615
|
+
// Delegated to the composer, which owns the "no silent winner" rule; the
|
|
616
|
+
// gate only gives the outcome a stable name.
|
|
617
|
+
if (args.filePolicy === undefined)
|
|
618
|
+
return { ok: true, policy: undefined };
|
|
619
|
+
const composed = composeSetupEpicPolicy(args.filePolicy, {
|
|
620
|
+
featureBranch: args.featureBranch,
|
|
621
|
+
reviewPolicy: args.reviewPolicy,
|
|
622
|
+
});
|
|
623
|
+
if (!composed.ok) {
|
|
624
|
+
return { ok: false, reason: "policy_source_contradiction", message: composed.error };
|
|
625
|
+
}
|
|
626
|
+
return { ok: true, policy: composed.policy };
|
|
627
|
+
}
|
|
544
628
|
/**
|
|
545
629
|
* Does this policy object explicitly supply the `notify.webhook_url` leaf?
|
|
546
630
|
*
|
|
@@ -775,6 +859,64 @@ export function validateEpicPlanSidecar(parsed) {
|
|
|
775
859
|
}
|
|
776
860
|
return { ok: true, plan: parsed, warnings };
|
|
777
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* The cited-document paths the server reported it could NOT resolve (BAPI-1027).
|
|
864
|
+
*
|
|
865
|
+
* Read off the server's own notice rather than re-derived from the plan: the
|
|
866
|
+
* server decides which citations matter and which it still needs evidence for,
|
|
867
|
+
* so the CLI resolves exactly what it was asked for and nothing more. That is
|
|
868
|
+
* what keeps the rule un-mirrored — the CLI reads a field, it does not reimplement
|
|
869
|
+
* a check.
|
|
870
|
+
*/
|
|
871
|
+
export function unresolvedCitedDocPaths(result) {
|
|
872
|
+
const paths = [];
|
|
873
|
+
const seen = new Set();
|
|
874
|
+
for (const notice of result.notices) {
|
|
875
|
+
if (notice.noticeCode !== CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED)
|
|
876
|
+
continue;
|
|
877
|
+
for (const path of notice.paths) {
|
|
878
|
+
if (seen.has(path))
|
|
879
|
+
continue;
|
|
880
|
+
seen.add(path);
|
|
881
|
+
paths.push(path);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return paths;
|
|
885
|
+
}
|
|
886
|
+
/** The server-side notice code naming citations that still need evidence. */
|
|
887
|
+
const CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED = "CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED";
|
|
888
|
+
/** The server-side TERMINAL duplicate-active-run code (BAPI-1027). */
|
|
889
|
+
export const ACTIVE_EPIC_RUN_EXISTS_CODE = "ACTIVE_EPIC_RUN_EXISTS";
|
|
890
|
+
/**
|
|
891
|
+
* True for a RETRYABLE optimistic-concurrency conflict (BAPI-1016 / BAPI-1027).
|
|
892
|
+
*
|
|
893
|
+
* Two conflict families reach this CLI and they call for opposite advice. A
|
|
894
|
+
* stale-CAS `row_version` means "re-read and retry"; a duplicate active run means
|
|
895
|
+
* "close or adopt, retrying is futile". They are told apart by the server's own
|
|
896
|
+
* `error_code`, never by HTTP status — the terminal one is a 409 and the
|
|
897
|
+
* retryable one is a 400 on these routes, which is precisely the sort of
|
|
898
|
+
* incidental detail that must not be what a renderer depends on.
|
|
899
|
+
*/
|
|
900
|
+
export function isRetryableConflict(err) {
|
|
901
|
+
return err instanceof ConductorBridgeApiError && err.errorCode === "CONFLICT";
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Render the ` (BAPI-1, BAPI-2 · docs/x.md)` subject of one rejection line.
|
|
905
|
+
*
|
|
906
|
+
* Kept to identifiers the operator needs to act — node keys, a path, a revision
|
|
907
|
+
* identifier. Specification content, credentials, and raw HTTP bodies never
|
|
908
|
+
* appear here.
|
|
909
|
+
*/
|
|
910
|
+
export function renderRejectionSubject(rejection) {
|
|
911
|
+
const parts = [];
|
|
912
|
+
if (rejection.ticketKeys.length > 0)
|
|
913
|
+
parts.push(rejection.ticketKeys.join(", "));
|
|
914
|
+
if (rejection.migrationId)
|
|
915
|
+
parts.push(`revision ${rejection.migrationId}`);
|
|
916
|
+
else if (rejection.path)
|
|
917
|
+
parts.push(rejection.path);
|
|
918
|
+
return parts.length > 0 ? ` (${parts.join(" · ")})` : "";
|
|
919
|
+
}
|
|
778
920
|
/** Iterative DFS cycle detection; returns the offending path or null. */
|
|
779
921
|
function findCycle(keys, adjacency) {
|
|
780
922
|
const WHITE = 0;
|
|
@@ -809,58 +951,85 @@ function findCycle(keys, adjacency) {
|
|
|
809
951
|
return null;
|
|
810
952
|
}
|
|
811
953
|
/** Propose a conductor epic feature branch name for an epic key (BAPI-655). */
|
|
812
|
-
function proposeFeatureBranchName(epicKey) {
|
|
954
|
+
export function proposeFeatureBranchName(epicKey) {
|
|
813
955
|
return `epic/${epicKey}`;
|
|
814
956
|
}
|
|
957
|
+
/**
|
|
958
|
+
* The plan-node count at or above which a no-flag run derives its own epic
|
|
959
|
+
* branch (BAPI-1009, R-4).
|
|
960
|
+
*
|
|
961
|
+
* A single-node plan is not an epic in any meaningful sense — it is one ticket,
|
|
962
|
+
* and plain `start-tickets` puts one ticket's PR straight onto the repository
|
|
963
|
+
* base branch. Cutting an `epic/<KEY>` branch, seeding a per-epic index scope,
|
|
964
|
+
* and opening an integration PR for it would add a whole provisioning sequence
|
|
965
|
+
* (and a second PR to merge) for no isolation benefit. Two or more nodes is
|
|
966
|
+
* where children can depend on one another, which is exactly the case the epic
|
|
967
|
+
* branch exists to serve.
|
|
968
|
+
*/
|
|
969
|
+
export const EPIC_BRANCH_MIN_PLAN_NODES = 2;
|
|
815
970
|
/**
|
|
816
971
|
* Resolve the epic feature branch selection for this invocation.
|
|
817
972
|
*
|
|
818
|
-
* Returns a
|
|
819
|
-
*
|
|
973
|
+
* Returns a branch name, or `undefined` to continue on the repository base
|
|
974
|
+
* branch. Pure and synchronous: BAPI-1009 removed the interactive proposal, so
|
|
975
|
+
* this asks nothing and reads no I/O.
|
|
820
976
|
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
977
|
+
* Precedence, in order:
|
|
978
|
+
*
|
|
979
|
+
* 1. `--into-base` returns `undefined` unconditionally. It is the explicit
|
|
980
|
+
* opt-out, and it wins over the derived default for every node count.
|
|
981
|
+
* 2. An explicit `--feature-branch <name>` is already confirmed + validated at
|
|
982
|
+
* parse time and is returned unchanged. It also forces an epic branch for a
|
|
983
|
+
* SINGLE-node plan, which rule 4 would otherwise leave on base.
|
|
984
|
+
* 3. A `--policy-file` that already declares a branch has ALREADY answered this
|
|
985
|
+
* question (BAPI-754), so no CLI branch is added — composition would then
|
|
986
|
+
* have to arbitrate between two answers.
|
|
987
|
+
* 4. A plan with fewer than {@link EPIC_BRANCH_MIN_PLAN_NODES} nodes returns
|
|
988
|
+
* `undefined` (R-4).
|
|
989
|
+
* 5. Otherwise the deterministic {@link proposeFeatureBranchName} value.
|
|
990
|
+
*
|
|
991
|
+
* BAPI-1009 deliberately removed the TTY/`--json` early return that used to
|
|
992
|
+
* produce `undefined` here. Branch strategy that depended on whether stdout was
|
|
993
|
+
* a terminal meant a CI run and an operator's run of the SAME command bootstrapped
|
|
994
|
+
* two different topologies — and the CI one was the one the shadow-index bootstrap
|
|
995
|
+
* was never exercised against.
|
|
833
996
|
*/
|
|
834
|
-
|
|
997
|
+
export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclaresBranch = false) {
|
|
998
|
+
if (opts.intoBase)
|
|
999
|
+
return undefined;
|
|
835
1000
|
if (opts.featureBranch !== undefined)
|
|
836
1001
|
return opts.featureBranch;
|
|
837
1002
|
if (policyDeclaresBranch)
|
|
838
1003
|
return undefined;
|
|
839
|
-
if (
|
|
1004
|
+
if (planNodeCount < EPIC_BRANCH_MIN_PLAN_NODES)
|
|
840
1005
|
return undefined;
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
deps.errorLog(` Invalid branch name: ${reason} Try again, or press Enter for the base branch.`);
|
|
860
|
-
continue; // redisplay for correction; never silently rewrite the value
|
|
861
|
-
}
|
|
862
|
-
return answer;
|
|
1006
|
+
return proposeFeatureBranchName(opts.epicKey);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* One line naming the selected branch strategy AND why it was selected.
|
|
1010
|
+
*
|
|
1011
|
+
* BAPI-1009: the "why" is the load-bearing half. The default changed, so an
|
|
1012
|
+
* operator reading `epic/BAPI-1009` needs to know whether they asked for it,
|
|
1013
|
+
* their policy file asked for it, or the multi-node default derived it — those
|
|
1014
|
+
* three have different recovery steps when the name is not the one they wanted.
|
|
1015
|
+
*/
|
|
1016
|
+
function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch) {
|
|
1017
|
+
const cutNote = "cut on origin with local git at the canonical indexed commit; " +
|
|
1018
|
+
"validated server-side at approval";
|
|
1019
|
+
if (featureBranch !== undefined) {
|
|
1020
|
+
if (opts.featureBranch !== undefined) {
|
|
1021
|
+
return `${featureBranch} (explicit --feature-branch override; ${cutNote})`;
|
|
1022
|
+
}
|
|
1023
|
+
return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote})`;
|
|
863
1024
|
}
|
|
1025
|
+
if (opts.intoBase) {
|
|
1026
|
+
return "repository base branch — epic branch disabled by --into-base; every child PR targets base";
|
|
1027
|
+
}
|
|
1028
|
+
if (policyDeclaresBranch) {
|
|
1029
|
+
return "from the policy file's base_branch (the file already answered this)";
|
|
1030
|
+
}
|
|
1031
|
+
return (`repository base branch — a single-node plan keeps base, matching start-tickets ` +
|
|
1032
|
+
`(pass --feature-branch to force ${proposeFeatureBranchName(opts.epicKey)} anyway)`);
|
|
864
1033
|
}
|
|
865
1034
|
function errorDetail(err) {
|
|
866
1035
|
if (err instanceof ConductorBridgeApiError) {
|
|
@@ -941,6 +1110,36 @@ function emitRefusal(deps, opts, result) {
|
|
|
941
1110
|
* the newer commit.
|
|
942
1111
|
*/
|
|
943
1112
|
export const SETUP_EPIC_SCOPE_RECOVERY_COMMAND = "re-run this same setup-epic command (approval is idempotent and re-drives the scope's seed and verification)";
|
|
1113
|
+
/**
|
|
1114
|
+
* The canonical one-line recovery for a scope collision (BAPI-1009).
|
|
1115
|
+
*
|
|
1116
|
+
* Repeated VERBATIM as the epic-run gate snapshot's shadow-hold recovery hint
|
|
1117
|
+
* (``api/library/epic_conductor/gate_snapshot_service.py``), so an operator who
|
|
1118
|
+
* reads the diagnosis on the status surface and an operator who reads it from
|
|
1119
|
+
* the CLI are told to do the same thing in the same words.
|
|
1120
|
+
*/
|
|
1121
|
+
export const SETUP_EPIC_SCOPE_REDRIVE_RECOVERY = "Re-drive the cut at the newer commit.";
|
|
1122
|
+
/**
|
|
1123
|
+
* How long `setup-epic` waits for a scope cut before acknowledging it as
|
|
1124
|
+
* DEFERRED (BAPI-1054).
|
|
1125
|
+
*
|
|
1126
|
+
* Six polls at the shared 5-second interval — roughly 30 seconds. It is a fixed
|
|
1127
|
+
* value, injectable for tests but deliberately NOT operator-configurable, so
|
|
1128
|
+
* "returns promptly" is one testable number rather than a range that depends on
|
|
1129
|
+
* how the operator invoked the command.
|
|
1130
|
+
*
|
|
1131
|
+
* It bounds THIS caller only. {@link SCOPE_BOOTSTRAP_MAX_POLLS} is untouched, so
|
|
1132
|
+
* anything else that drives the shared poll keeps the generous default a real
|
|
1133
|
+
* seed needs. Waiting the full ~20 minutes was never buying information: the run
|
|
1134
|
+
* is already created and approved, the reconciler holds dispatch until the scope
|
|
1135
|
+
* is ready either way, and the only thing the long block changed was whether the
|
|
1136
|
+
* operator's terminal was occupied while it happened.
|
|
1137
|
+
*/
|
|
1138
|
+
export const SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS = 6;
|
|
1139
|
+
/** The deferred window in seconds, derived so prose cannot drift from the bound. */
|
|
1140
|
+
export const SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS = Math.round((SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS * SCOPE_BOOTSTRAP_POLL_INTERVAL_MS) / 1000);
|
|
1141
|
+
/** The exact phrase the deferred acknowledgement leads with. Named so tests pin it. */
|
|
1142
|
+
export const SETUP_EPIC_CUT_DEFERRED_PHRASE = "created, cut deferred";
|
|
944
1143
|
/** The lead sentence of every fail-closed canonical-parse refusal (BAPI-850). */
|
|
945
1144
|
export const SETUP_EPIC_PARSE_FIRST_MESSAGE = "parse the repository first";
|
|
946
1145
|
/**
|
|
@@ -1049,9 +1248,20 @@ async function resolveScopeIdForRun(access, fetchImpl, epicRunId) {
|
|
|
1049
1248
|
* ONE run), so it is always named explicitly rather than left as a placeholder.
|
|
1050
1249
|
*/
|
|
1051
1250
|
function scopeFailureGuidance(reason, featureBranch, epicRunId) {
|
|
1251
|
+
// BAPI-1009: both collision categories lead with the SAME canonical sentence the
|
|
1252
|
+
// run snapshot's recovery hint repeats, then keep their own remediation detail.
|
|
1253
|
+
// The default now cuts a branch where it previously did not, so a collision is
|
|
1254
|
+
// reachable by operators who never chose a branch strategy at all — the recovery
|
|
1255
|
+
// has to read identically wherever they happen to encounter it.
|
|
1052
1256
|
if (reason === "canonical_index_advanced") {
|
|
1053
|
-
return (
|
|
1054
|
-
`
|
|
1257
|
+
return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} The canonical index advanced before the ` +
|
|
1258
|
+
`scope could be seeded: delete origin/${featureBranch}, abandon this run ` +
|
|
1259
|
+
`(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
|
|
1260
|
+
}
|
|
1261
|
+
if (reason === "head_mismatch") {
|
|
1262
|
+
return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} origin/${featureBranch} does not sit at the ` +
|
|
1263
|
+
`commit this scope was cut at, and setup-epic never force-updates an existing ` +
|
|
1264
|
+
`ref: delete origin/${featureBranch}, abandon this run ` +
|
|
1055
1265
|
`(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
|
|
1056
1266
|
}
|
|
1057
1267
|
return `Recovery: ${SETUP_EPIC_SCOPE_RECOVERY_COMMAND}.`;
|
|
@@ -1064,11 +1274,20 @@ function scopeFailureGuidance(reason, featureBranch, epicRunId) {
|
|
|
1064
1274
|
* detail: the label leads, the scope id never does, and no credential appears.
|
|
1065
1275
|
*/
|
|
1066
1276
|
function describeScopeTransition(state, status) {
|
|
1067
|
-
|
|
1277
|
+
// BAPI-1054: bounded on the way in, so an unrecognized server state renders as
|
|
1278
|
+
// `unknown` rather than as itself. `SCOPE_LIFECYCLE_LABELS[state] ?? state`
|
|
1279
|
+
// fell through to the raw value for anything outside the known set.
|
|
1280
|
+
const bounded = boundScopeLifecycleLabel(state);
|
|
1281
|
+
const label = SCOPE_LIFECYCLE_LABELS[bounded] ?? bounded;
|
|
1068
1282
|
if (state === "ready") {
|
|
1069
1283
|
return `Scope: ${label} — indexed at ${status.indexed_commit_sha ?? "unknown"} (scope ${status.scope_id})`;
|
|
1070
1284
|
}
|
|
1071
1285
|
if (state === "failed") {
|
|
1286
|
+
// `last_error` is the bounded failure CATEGORY, not free text: the terminal
|
|
1287
|
+
// branch already surfaces the same value as `index_scope.failure_reason` and
|
|
1288
|
+
// switches on it in `scopeFailureGuidance`. Dropping it here would lose the
|
|
1289
|
+
// one fact that distinguishes a recoverable collision from a seed failure,
|
|
1290
|
+
// while removing nothing that is not already reported.
|
|
1072
1291
|
return `Scope: ${label} — ${status.last_error ?? "unknown"} (scope ${status.scope_id})`;
|
|
1073
1292
|
}
|
|
1074
1293
|
return `Scope: ${label}…`;
|
|
@@ -1135,22 +1354,47 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1135
1354
|
}
|
|
1136
1355
|
const policyDeclaresBranch = filePolicy !== undefined &&
|
|
1137
1356
|
["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
|
|
1357
|
+
// --- Feature branch selection (BAPI-655, defaulted by BAPI-1009) ---------
|
|
1358
|
+
// Resolved from the VALIDATED plan's node count, the flags, and the policy
|
|
1359
|
+
// file — all of which are already in hand — and deliberately BEFORE the
|
|
1360
|
+
// `--local-only` return and before credential resolution. Selection reads no
|
|
1361
|
+
// network and asks no question, so running it here is what makes `--local-only`
|
|
1362
|
+
// and an ordinary invocation report the same strategy for the same plan.
|
|
1363
|
+
const featureBranch = resolveFeatureBranchSelection(opts, plan.nodes.length, policyDeclaresBranch);
|
|
1364
|
+
const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch);
|
|
1365
|
+
// --- Consolidated policy preflight (BAPI-1054) ---------------------------
|
|
1366
|
+
// ONE gate, run BEFORE credential resolution and before any request, over the
|
|
1367
|
+
// whole flag-backed choice-set. It sits above the `--local-only` return
|
|
1368
|
+
// deliberately: a dry run that refuses a different set from the real run is
|
|
1369
|
+
// worth very little, and this branch previously composed the policy without
|
|
1370
|
+
// evaluating the `--into-base` contradiction alongside it.
|
|
1371
|
+
//
|
|
1372
|
+
// The server's `RunPolicy` schema stays authoritative. This validates the
|
|
1373
|
+
// choices the CLI itself surfaces and nothing else — a field-named `422` is
|
|
1374
|
+
// still the better error for everything inside the policy file.
|
|
1375
|
+
const policyGate = preflightSetupEpicPolicy({
|
|
1376
|
+
intoBase: opts.intoBase,
|
|
1377
|
+
policyFile: opts.policyFile,
|
|
1378
|
+
filePolicy,
|
|
1379
|
+
policyDeclaresBranch,
|
|
1380
|
+
featureBranch,
|
|
1381
|
+
reviewPolicy: opts.reviewPolicy,
|
|
1382
|
+
});
|
|
1383
|
+
if (!policyGate.ok) {
|
|
1384
|
+
deps.errorLog(policyGate.message);
|
|
1385
|
+
return 1;
|
|
1386
|
+
}
|
|
1138
1387
|
// --- --local-only --dry-run: stop here, offline and explicitly partial ----
|
|
1139
1388
|
// Nothing below this point is reachable without the network, and the whole
|
|
1140
1389
|
// point of the flag is to not touch it — not even to resolve a credential.
|
|
1141
1390
|
if (opts.localOnly) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
featureBranch: opts.featureBranch,
|
|
1145
|
-
reviewPolicy: opts.reviewPolicy,
|
|
1146
|
-
});
|
|
1147
|
-
if (!composed.ok) {
|
|
1148
|
-
deps.errorLog(composed.error);
|
|
1149
|
-
return 1;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1391
|
+
// The policy gate above already composed and validated; nothing is repeated
|
|
1392
|
+
// here, so the offline path and the real path refuse the same set.
|
|
1152
1393
|
say(`Epic: ${opts.epicKey}`);
|
|
1153
1394
|
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
|
|
1395
|
+
// BAPI-1009: the same node-count-derived strategy an ordinary run would take,
|
|
1396
|
+
// stated without cutting a branch, reading the network, or touching git.
|
|
1397
|
+
say(`Branch: ${branchStrategyLine}`);
|
|
1154
1398
|
say(`Local hash: ${localHash}`);
|
|
1155
1399
|
for (const w of warnings)
|
|
1156
1400
|
say(` [warn] ${w}`);
|
|
@@ -1195,35 +1439,19 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1195
1439
|
say(`Local hash: ${localHash}`);
|
|
1196
1440
|
for (const w of warnings)
|
|
1197
1441
|
say(` [warn] ${w}`);
|
|
1198
|
-
// ---
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1212
|
-
// --- Compose the requested run policy (BAPI-754) -------------------------
|
|
1213
|
-
// A contradiction between the file and the flags is refused here, before the
|
|
1214
|
-
// pre-check and long before any create, so no request is dispatched under a
|
|
1215
|
-
// policy nobody chose.
|
|
1216
|
-
let requestedPolicy;
|
|
1217
|
-
if (filePolicy !== undefined) {
|
|
1218
|
-
const composed = composeSetupEpicPolicy(filePolicy, {
|
|
1219
|
-
featureBranch,
|
|
1220
|
-
reviewPolicy: opts.reviewPolicy,
|
|
1221
|
-
});
|
|
1222
|
-
if (!composed.ok) {
|
|
1223
|
-
deps.errorLog(composed.error);
|
|
1224
|
-
return 1;
|
|
1225
|
-
}
|
|
1226
|
-
requestedPolicy = composed.policy;
|
|
1442
|
+
// --- Branch strategy (BAPI-1009) ----------------------------------------
|
|
1443
|
+
// Already resolved above, before the network was touched. Reported HERE, ahead
|
|
1444
|
+
// of any secondary policy detail, because it is the one decision that changes
|
|
1445
|
+
// where every child PR lands — an operator who reads nothing else must still
|
|
1446
|
+
// see which topology this run is about to bootstrap.
|
|
1447
|
+
say(`Branch: ${branchStrategyLine}`);
|
|
1448
|
+
// --- The requested run policy (BAPI-754; gated by BAPI-1054) -------------
|
|
1449
|
+
// Composition and every contradiction refusal already happened in the policy
|
|
1450
|
+
// preflight above, before credentials were resolved. This is the SAME object
|
|
1451
|
+
// that passed the gate — not a second composition — so the payload the create
|
|
1452
|
+
// request carries is exactly what was validated.
|
|
1453
|
+
const requestedPolicy = policyGate.policy;
|
|
1454
|
+
if (requestedPolicy !== undefined) {
|
|
1227
1455
|
say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
|
|
1228
1456
|
}
|
|
1229
1457
|
// --- Effective feature branch (BAPI-850) ----------------------------------
|
|
@@ -1291,16 +1519,55 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1291
1519
|
// --- Feature-branch conflict guard against an existing live run ----------
|
|
1292
1520
|
// A selected branch that disagrees with the existing run's stored base branch
|
|
1293
1521
|
// must fail closed rather than patch or silently change that run. A matching
|
|
1294
|
-
// branch is allowed (the rerun re-validates remote provisioning at approval)
|
|
1295
|
-
//
|
|
1522
|
+
// branch is allowed (the rerun re-validates remote provisioning at approval).
|
|
1523
|
+
//
|
|
1524
|
+
// BAPI-1009: `featureBranch` can now be DERIVED, so this guard also catches the
|
|
1525
|
+
// new case — a live run created under the old direct-to-base default, re-run
|
|
1526
|
+
// after the default flipped. Continuing would dispatch children at base while
|
|
1527
|
+
// every message on screen named an epic branch. The remedy differs by how the
|
|
1528
|
+
// branch was chosen, so the guidance names the flag that actually applies:
|
|
1529
|
+
// dropping `--feature-branch` reuses the run only when the flag is what asked
|
|
1530
|
+
// for the branch; when the DEFAULT asked, `--into-base` is the way to say
|
|
1531
|
+
// "keep this run on base".
|
|
1296
1532
|
if (existingRunId && featureBranch !== undefined && existingBaseBranch !== featureBranch) {
|
|
1533
|
+
const reuseAdvice = opts.featureBranch !== undefined
|
|
1534
|
+
? "Re-run without --feature-branch to reuse it unchanged"
|
|
1535
|
+
: `Re-run with --into-base to reuse it on the base branch it was created with`;
|
|
1297
1536
|
deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) whose feature ` +
|
|
1298
1537
|
`branch is ${existingBaseBranch ? `'${existingBaseBranch}'` : "unset (base branch)"}, ` +
|
|
1299
|
-
`which conflicts with the
|
|
1300
|
-
`retarget or rebuild an existing run.
|
|
1301
|
-
`
|
|
1538
|
+
`which conflicts with the selected '${featureBranch}'. setup-epic will not ` +
|
|
1539
|
+
`retarget or rebuild an existing run. ${reuseAdvice}, or abandon the run to ` +
|
|
1540
|
+
`start over on a new branch.`);
|
|
1302
1541
|
return 1;
|
|
1303
1542
|
}
|
|
1543
|
+
// BAPI-1009: the mirror image — `--into-base` against a run that already lives
|
|
1544
|
+
// on an epic branch. The guard above cannot see this one, because `--into-base`
|
|
1545
|
+
// resolves to NO selected branch and so never disagrees with a stored value.
|
|
1546
|
+
// Reusing silently would report base mode for a run whose scope, cut, and
|
|
1547
|
+
// integration PR all belong to the epic branch.
|
|
1548
|
+
//
|
|
1549
|
+
// A stored `base_branch` is NOT by itself an epic branch: a run created with
|
|
1550
|
+
// `base_branch: "main"` is ALREADY on base, and refusing it would be a false
|
|
1551
|
+
// refusal. So the comparison is made against the repository's configured base,
|
|
1552
|
+
// resolved exactly the way the cut path below resolves it — same client, same
|
|
1553
|
+
// `"main"` fallback for an unreadable config — because "a branch equal to the
|
|
1554
|
+
// configured base is not scope-bearing" is an existing rule here, and a second
|
|
1555
|
+
// rule that disagreed with it would be worse than the extra read.
|
|
1556
|
+
if (existingRunId && opts.intoBase && existingBaseBranch !== null) {
|
|
1557
|
+
const configuredBase = await getConfigFieldBaseBranch(access, deps.fetch);
|
|
1558
|
+
const repositoryBase = configuredBase.ok && configuredBase.value.base_branch !== null
|
|
1559
|
+
? configuredBase.value.base_branch
|
|
1560
|
+
: "main";
|
|
1561
|
+
if (existingBaseBranch !== repositoryBase) {
|
|
1562
|
+
deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) on feature branch ` +
|
|
1563
|
+
`'${existingBaseBranch}', not the repository base '${repositoryBase}', and ` +
|
|
1564
|
+
`--into-base asks for the repository base branch. setup-epic will not retarget ` +
|
|
1565
|
+
`an existing run. Re-run without --into-base to reuse it unchanged, or abandon ` +
|
|
1566
|
+
`the run (conductor abandon-run --epic-run-id ${existingRunId}) and recreate ` +
|
|
1567
|
+
`it on base.`);
|
|
1568
|
+
return 1;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1304
1571
|
// --- Review-policy conflict guard against an existing live run ----------
|
|
1305
1572
|
// BAPI-679, analogous to the feature-branch guard above. An explicitly selected
|
|
1306
1573
|
// policy that disagrees with a live run's effective policy must fail closed:
|
|
@@ -1325,12 +1592,55 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1325
1592
|
// row and an automation-start debit on the way to finding that out.
|
|
1326
1593
|
let validated = null;
|
|
1327
1594
|
let validateLaneMissing = false;
|
|
1595
|
+
// BAPI-1027 — the evidence the successful validation was given, carried onto
|
|
1596
|
+
// the later store call so both lanes judge the plan against the same facts.
|
|
1597
|
+
let trackedPathEvidence;
|
|
1328
1598
|
try {
|
|
1329
1599
|
validated = await validateEpicPlan(access, {
|
|
1330
1600
|
planVersion: plan.plan_version,
|
|
1331
1601
|
planBlob: plan,
|
|
1332
1602
|
epicKey: opts.epicKey,
|
|
1333
1603
|
}, deps.fetch);
|
|
1604
|
+
// BAPI-1027 — phase two of the two-phase exchange. The server names the
|
|
1605
|
+
// cited-document paths it could not resolve; only THEN does the CLI touch
|
|
1606
|
+
// git, and only for those exact paths. The rules stay server-side: the CLI
|
|
1607
|
+
// supplies facts and never decides whether a plan passes.
|
|
1608
|
+
//
|
|
1609
|
+
// Skipped entirely when the first response already carries blocking
|
|
1610
|
+
// rejections. Those are unrelated to trackedness, the plan is going to be
|
|
1611
|
+
// refused either way, and running git plus a second billable-free round trip
|
|
1612
|
+
// to enrich a verdict that will not change is pure latency.
|
|
1613
|
+
const unresolved = unresolvedCitedDocPaths(validated);
|
|
1614
|
+
if (validated.rejections.length === 0 && unresolved.length > 0) {
|
|
1615
|
+
const oracleRun = deps.runCommand;
|
|
1616
|
+
if (!oracleRun) {
|
|
1617
|
+
deps.errorLog(`Validation could not be completed: ${unresolved.length} cited document ` +
|
|
1618
|
+
`path(s) need a git trackedness check, but no local command runner is ` +
|
|
1619
|
+
`available in this environment. No run was created and no ` +
|
|
1620
|
+
`automation-start charge occurred.`);
|
|
1621
|
+
return 1;
|
|
1622
|
+
}
|
|
1623
|
+
const resolution = await resolveTrackedPaths(deps.cwd, unresolved, {
|
|
1624
|
+
runCommand: oracleRun,
|
|
1625
|
+
});
|
|
1626
|
+
if (!resolution.ok) {
|
|
1627
|
+
// An unresolved oracle is "validation could not be completed", NEVER
|
|
1628
|
+
// "these documents are missing". Continuing would store a plan whose
|
|
1629
|
+
// citations were never checked while reporting a clean preflight.
|
|
1630
|
+
deps.errorLog(`Validation could not be completed: the cited documents in this plan ` +
|
|
1631
|
+
`could not be checked against version control.\n${resolution.error}\n` +
|
|
1632
|
+
`No run was created and no automation-start charge occurred. Re-run ` +
|
|
1633
|
+
`from inside the repository working tree once git is available.`);
|
|
1634
|
+
return 1;
|
|
1635
|
+
}
|
|
1636
|
+
trackedPathEvidence = resolution.facts;
|
|
1637
|
+
validated = await validateEpicPlan(access, {
|
|
1638
|
+
planVersion: plan.plan_version,
|
|
1639
|
+
planBlob: plan,
|
|
1640
|
+
epicKey: opts.epicKey,
|
|
1641
|
+
trackedPaths: trackedPathEvidence,
|
|
1642
|
+
}, deps.fetch);
|
|
1643
|
+
}
|
|
1334
1644
|
}
|
|
1335
1645
|
catch (err) {
|
|
1336
1646
|
const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
|
|
@@ -1353,16 +1663,36 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1353
1663
|
`occurred.\nDetail: ${errorDetail(err)}`);
|
|
1354
1664
|
return 1;
|
|
1355
1665
|
}
|
|
1666
|
+
else if (isRetryableConflict(err)) {
|
|
1667
|
+
// BAPI-1027 — a RETRYABLE stale-CAS conflict (post-BAPI-1016). The plan is
|
|
1668
|
+
// fine and so is the epic; someone else wrote concurrently between the read
|
|
1669
|
+
// and this call. Rendered with retry language and deliberately WITHOUT any
|
|
1670
|
+
// close/adopt/abandon wording: telling an operator to abandon a run over a
|
|
1671
|
+
// conflict they should simply retry is the exact confusion the distinct
|
|
1672
|
+
// codes exist to prevent.
|
|
1673
|
+
deps.errorLog(`Plan changed concurrently; re-read and retry validation.\n` +
|
|
1674
|
+
`Nothing was created, stored, or charged — this is a transient ` +
|
|
1675
|
+
`concurrency conflict, not a problem with your plan or your epic.\n` +
|
|
1676
|
+
`Detail: ${errorDetail(err)}`);
|
|
1677
|
+
return 1;
|
|
1678
|
+
}
|
|
1356
1679
|
else if (status === 409) {
|
|
1357
1680
|
// The epic is WEDGED (multiple active runs), which says nothing about the
|
|
1358
1681
|
// plan. Reporting this as "server validation: failed" would send the
|
|
1359
1682
|
// operator to fix a sidecar that is perfectly fine. The pre-check above
|
|
1360
1683
|
// normally catches this first; this branch covers the race where a
|
|
1361
1684
|
// duplicate run appears between the pre-check and the validate call.
|
|
1685
|
+
//
|
|
1686
|
+
// BAPI-1027 — TERMINAL, and said so plainly: the server now names this
|
|
1687
|
+
// `ACTIVE_EPIC_RUN_EXISTS`, distinct from the retryable conflict handled
|
|
1688
|
+
// above. Retrying never clears it. The branch still fires on a bare 409
|
|
1689
|
+
// from a server that predates the code, so an older deployment keeps the
|
|
1690
|
+
// behavior it always had.
|
|
1362
1691
|
deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — the plan could not be ` +
|
|
1363
1692
|
`validated because the epic itself is wedged, and every plan call will ` +
|
|
1364
|
-
`keep failing. This is
|
|
1365
|
-
`
|
|
1693
|
+
`keep failing. This is TERMINAL: it will not clear on retry, and it is ` +
|
|
1694
|
+
`NOT a problem with your plan. Adopt the run you want to keep, or ` +
|
|
1695
|
+
`abandon the duplicate:\n` +
|
|
1366
1696
|
` conductor abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
|
|
1367
1697
|
`No run was created and no automation-start charge occurred.\n` +
|
|
1368
1698
|
`Detail: ${errorDetail(err)}`);
|
|
@@ -1374,9 +1704,37 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1374
1704
|
return 1;
|
|
1375
1705
|
}
|
|
1376
1706
|
}
|
|
1707
|
+
if (validated && validated.rejections.length > 0) {
|
|
1708
|
+
// BAPI-1027 — a lint rejection is the plan's own fault and is terminal here:
|
|
1709
|
+
// no run is created, nothing is stored, and no charge occurs. Rendered to
|
|
1710
|
+
// STDERR (never stdout, which under --json carries the single result object
|
|
1711
|
+
// and which in MCP server mode is the stdio transport), one compact line per
|
|
1712
|
+
// rejection so an operator can fix every fault in one pass instead of
|
|
1713
|
+
// rediscovering them one round trip at a time.
|
|
1714
|
+
deps.errorLog(`Plan needs attention: ${validated.rejections.length} lint rejection(s).`);
|
|
1715
|
+
for (const rejection of validated.rejections) {
|
|
1716
|
+
deps.errorLog(` ${rejection.reasonCode}${renderRejectionSubject(rejection)}: ${rejection.explanation}`);
|
|
1717
|
+
}
|
|
1718
|
+
deps.errorLog("The plan was NOT stored and no run was created; no automation-start charge " +
|
|
1719
|
+
"occurred. Fix the plan file and re-run — setup-epic never rewrites a plan, " +
|
|
1720
|
+
"because that would change its hash.");
|
|
1721
|
+
return 1;
|
|
1722
|
+
}
|
|
1377
1723
|
if (validated) {
|
|
1378
1724
|
say("Validation: server validation: passed");
|
|
1725
|
+
say(" Plan ready to store");
|
|
1726
|
+
say(" No lint rejections found");
|
|
1379
1727
|
say(` plan hash ${validated.planHash}`);
|
|
1728
|
+
// BAPI-1027 — "nothing was found" and "everything was checked" are different
|
|
1729
|
+
// statements, and reporting the first as the second is the false assurance
|
|
1730
|
+
// this lane exists to remove. An incomplete evaluation is a WARNING on
|
|
1731
|
+
// stderr: the plan is storable, and the operator is told what went unchecked.
|
|
1732
|
+
if (!validated.evaluationComplete) {
|
|
1733
|
+
for (const notice of validated.notices) {
|
|
1734
|
+
deps.errorLog(`WARNING: ${notice.noticeCode}: ${notice.explanation}` +
|
|
1735
|
+
(notice.paths.length > 0 ? `\n Unchecked: ${notice.paths.join(", ")}` : ""));
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1380
1738
|
// BAPI-848 — three distinct outcomes, never collapsed into success language.
|
|
1381
1739
|
// "0 edges inserted" used to read as proof the siblings could not conflict.
|
|
1382
1740
|
// It never was: serialization only sees paths BOTH nodes declared, and the
|
|
@@ -1622,7 +1980,17 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1622
1980
|
else {
|
|
1623
1981
|
// The pre-check said there was no live run, but create found one — a
|
|
1624
1982
|
// concurrent setup won the race. Treat it exactly like the reuse path.
|
|
1625
|
-
|
|
1983
|
+
//
|
|
1984
|
+
// BAPI-1027 — this ADOPTION is the normal answer to "a second setup for
|
|
1985
|
+
// one epic", not an error: `create_epic_run` returns the existing
|
|
1986
|
+
// non-terminal run untouched, and the `uq_epic_runs_active_repo_epic_key`
|
|
1987
|
+
// index is the race backstop that keeps it so. Naming the adopted run and
|
|
1988
|
+
// its status, rather than reporting a bare reuse, is what lets an operator
|
|
1989
|
+
// decide between continuing on it and closing it.
|
|
1990
|
+
say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
|
|
1991
|
+
say(` No second run was created — one epic has at most one active ` +
|
|
1992
|
+
`run. Continue on this one, or abandon it first with ` +
|
|
1993
|
+
`\`conductor abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
|
|
1626
1994
|
if (requestedPolicy !== undefined) {
|
|
1627
1995
|
const outcome = await applyReusedRunPolicy({
|
|
1628
1996
|
access,
|
|
@@ -1642,6 +2010,18 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1642
2010
|
catch (err) {
|
|
1643
2011
|
const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
|
|
1644
2012
|
if (status === 422 && requestedPolicy !== undefined) {
|
|
2013
|
+
// BAPI-1054 investigated whether `errorDetail`'s `bodyPreview` could echo
|
|
2014
|
+
// policy content here — a run policy can carry `notify.webhook_url`, and
|
|
2015
|
+
// a FastAPI validation detail routinely repeats the submitted `input`.
|
|
2016
|
+
//
|
|
2017
|
+
// It cannot, and the guard is one layer down where it belongs:
|
|
2018
|
+
// `formatValidationDetailItem` (conductor/bridge-api-client.ts) reduces a
|
|
2019
|
+
// 422 detail to `<loc path>: <msg>` — field NAMES and the validator's own
|
|
2020
|
+
// message, with `input` never read and the transport-only `body` prefix
|
|
2021
|
+
// dropped — and `redactDiagnosticValues` then scrubs known secrets.
|
|
2022
|
+
// Re-sanitizing here would only destroy the field name that makes the
|
|
2023
|
+
// server's field-named 422 actionable. The property is pinned by the
|
|
2024
|
+
// regression test below rather than left to this comment.
|
|
1645
2025
|
deps.errorLog(`The run policy from '${opts.policyFile}' was rejected by the server.\n` +
|
|
1646
2026
|
`${errorDetail(err)}\n` +
|
|
1647
2027
|
`No run was created and no automation-start charge occurred. Fix the ` +
|
|
@@ -1685,7 +2065,22 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1685
2065
|
// and replace a stale record on its own, and there is nothing running to
|
|
1686
2066
|
// hand this binding to yet.
|
|
1687
2067
|
}
|
|
1688
|
-
|
|
2068
|
+
else if (manifestRead.kind !== "missing") {
|
|
2069
|
+
// NOT normal, and previously silent (BAPI-1029). `missing` genuinely
|
|
2070
|
+
// means "no local plane" — remote runs, CI, any non-plane workflow — and
|
|
2071
|
+
// stays quiet. `malformed` and `unreadable` mean a manifest EXISTS and
|
|
2072
|
+
// this build could not validate it, which is exactly what happens when
|
|
2073
|
+
// it was written by a newer build. A plane may well be running, and the
|
|
2074
|
+
// binding it needed just did not happen; saying nothing left the
|
|
2075
|
+
// operator to discover that at `plane down` time.
|
|
2076
|
+
const msg = `A local plane manifest exists but could not be validated (${manifestRead.error}), so ` +
|
|
2077
|
+
`run ${result.epic_run_id} was NOT bound to it. If a plane is running, \`plane down\` ` +
|
|
2078
|
+
"will not stop this run automatically — use " +
|
|
2079
|
+
`\`conductor stop-run --epic-run-id ${result.epic_run_id}\`. A manifest written by a ` +
|
|
2080
|
+
"NEWER build must be wound down with that build's `plane down`.";
|
|
2081
|
+
warnings.push(msg);
|
|
2082
|
+
say(`Plane: [warn] ${msg}`);
|
|
2083
|
+
}
|
|
1689
2084
|
}
|
|
1690
2085
|
catch (err) {
|
|
1691
2086
|
// The binding capability itself must never fail setup-epic as a whole;
|
|
@@ -1764,6 +2159,12 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1764
2159
|
planVersion: plan.plan_version,
|
|
1765
2160
|
planBlob: plan,
|
|
1766
2161
|
planHash: localHash,
|
|
2162
|
+
// BAPI-1027 — the same working-tree facts the dry run was validated
|
|
2163
|
+
// against. Without them the store lane would re-evaluate this plan with
|
|
2164
|
+
// NO evidence, report cited-document reachability as "not evaluated",
|
|
2165
|
+
// and quietly reach a different verdict than the preflight the operator
|
|
2166
|
+
// was just shown.
|
|
2167
|
+
...(trackedPathEvidence !== undefined ? { trackedPaths: trackedPathEvidence } : {}),
|
|
1767
2168
|
}, deps.fetch);
|
|
1768
2169
|
result.plan_stored = true;
|
|
1769
2170
|
const serverHash = stored?.plan_hash;
|
|
@@ -1870,8 +2271,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1870
2271
|
exitCode = 1;
|
|
1871
2272
|
}
|
|
1872
2273
|
else {
|
|
1873
|
-
say(`Scope: waiting for index scope
|
|
1874
|
-
|
|
2274
|
+
say(`Scope: waiting up to ~${SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS}s for index scope ` +
|
|
2275
|
+
`readiness; if it is still preparing, this returns and preparation continues server-side.`);
|
|
2276
|
+
const verdict = await pollIndexScopeLifecycle(cutProtocolDeps(deps, cutRunCommand ?? deps.runCommand ?? createExecFileRunCommand()), access, scopeId, {
|
|
2277
|
+
onTransition: (state, status) => say(describeScopeTransition(state, status)),
|
|
2278
|
+
// BAPI-1054: THIS caller's bound only. The shared default is untouched,
|
|
2279
|
+
// so nothing else that drives this poll waits any less than it did.
|
|
2280
|
+
maxPolls: SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS,
|
|
2281
|
+
});
|
|
1875
2282
|
if (verdict.kind === "ready") {
|
|
1876
2283
|
result.index_scope = {
|
|
1877
2284
|
scope_id: scopeId,
|
|
@@ -1916,20 +2323,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1916
2323
|
exitCode = 1;
|
|
1917
2324
|
}
|
|
1918
2325
|
else {
|
|
2326
|
+
// BAPI-1054 (AC-2): NOT a failure. The run is created and approved, and
|
|
2327
|
+
// the reconciler holds dispatch until the scope is ready — a safe hold.
|
|
2328
|
+
// Reporting it as exit 1 after a ~20-minute block made a perfectly good
|
|
2329
|
+
// run read as a failed command, which is the whole defect this closes.
|
|
2330
|
+
//
|
|
2331
|
+
// Every rendered value is bounded: the run id, the scope id, and a
|
|
2332
|
+
// lifecycle label the poll already collapsed to a known label,
|
|
2333
|
+
// `unreadable`, or `unknown`. No raw status, no server error text, no URL.
|
|
2334
|
+
const lastState = boundScopeLifecycleLabel(verdict.lastState);
|
|
1919
2335
|
result.index_scope = {
|
|
1920
2336
|
scope_id: scopeId,
|
|
1921
2337
|
cut_commit_sha: verdict.lastStatus?.cut_commit_sha ?? cutCommitSha,
|
|
1922
|
-
lifecycle_state:
|
|
2338
|
+
lifecycle_state: lastState,
|
|
1923
2339
|
ready: false,
|
|
1924
|
-
disposition: "
|
|
2340
|
+
disposition: "created_cut_deferred",
|
|
1925
2341
|
};
|
|
1926
|
-
|
|
1927
|
-
`
|
|
1928
|
-
`
|
|
1929
|
-
`reconciler will NOT dispatch tickets until the scope becomes ready. Check ` +
|
|
1930
|
-
`GET /jira/index-scope/status?repo_name=${access.repoName}&scope_id=${scopeId}, ` +
|
|
1931
|
-
`or re-run setup-epic to wait again.`);
|
|
1932
|
-
exitCode = 1;
|
|
2342
|
+
say(`Scope: ${SETUP_EPIC_CUT_DEFERRED_PHRASE} — run ${result.epic_run_id}, ` +
|
|
2343
|
+
`scope ${scopeId}, last observed state '${lastState}'. Scope preparation ` +
|
|
2344
|
+
`continues server-side; the reconciler holds dispatch until it is ready.`);
|
|
1933
2345
|
}
|
|
1934
2346
|
}
|
|
1935
2347
|
}
|
|
@@ -1947,7 +2359,16 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1947
2359
|
"order was used. Check that BAPI_BASE_URL points at the intended deployment.");
|
|
1948
2360
|
}
|
|
1949
2361
|
say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
|
|
1950
|
-
|
|
2362
|
+
// BAPI-1054: a deferred cut means the reconciler picks the run up but HOLDS
|
|
2363
|
+
// dispatch, so the unqualified "~30s" line would contradict the
|
|
2364
|
+
// acknowledgement printed moments earlier.
|
|
2365
|
+
if (result.index_scope?.disposition === "created_cut_deferred") {
|
|
2366
|
+
say("The server-side reconciler will pick it up within ~30s, but holds ticket " +
|
|
2367
|
+
"dispatch until the index scope finishes preparing.");
|
|
2368
|
+
}
|
|
2369
|
+
else {
|
|
2370
|
+
say("The server-side reconciler will pick it up within ~30s.");
|
|
2371
|
+
}
|
|
1951
2372
|
say("To execute claimed jobs on this machine, run:");
|
|
1952
2373
|
say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName}`);
|
|
1953
2374
|
}
|