@bridge_gpt/mcp-server 0.2.41 → 0.2.42
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 +10 -10
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +4 -4
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +752 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +27 -1
- package/build/conductor/cli.js +46 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +15 -15
- package/build/credentials-cli.js +3 -2
- package/build/doctor.js +107 -41
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +148 -26
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +167 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +235 -48
- package/build/executor/test-clock.js +3 -2
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +153 -204
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +202 -38
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +3 -3
- package/build/plane/defaults.js +4 -1
- package/build/plane/preflight.js +81 -10
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +74 -1
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +453 -78
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
* - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
|
|
30
30
|
* never enter argv, stdout, stderr, a journal line, or an error string.
|
|
31
31
|
*/
|
|
32
|
-
import { execFile } from "node:child_process";
|
|
33
32
|
import { promises as nodeFs } from "node:fs";
|
|
34
33
|
import os from "node:os";
|
|
35
34
|
import path from "node:path";
|
|
@@ -40,15 +39,33 @@ import { runGhCommand } from "../conductor/pr-discovery.js";
|
|
|
40
39
|
import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
|
|
41
40
|
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
42
41
|
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
43
|
-
import { getConfigFieldBaseBranch, getConductorReadiness, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState,
|
|
42
|
+
import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
|
|
44
43
|
import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
|
|
45
44
|
import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
|
|
46
45
|
import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
|
|
47
46
|
import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
|
|
47
|
+
import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
|
|
48
|
+
import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
|
|
49
|
+
// BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
|
|
50
|
+
// local-git helpers live in ONE shared module that `setup-epic` drives too. This
|
|
51
|
+
// file remains the pilot's owner of the preflight and of how a cut outcome is
|
|
52
|
+
// reported; the cut itself is performed by the shared module.
|
|
53
|
+
import { createExecFileRunCommand, firstOutputLine as firstLine, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, } from "./cut-protocol.js";
|
|
54
|
+
// Re-exported so existing importers of the pilot's normalizer keep compiling.
|
|
55
|
+
export { normalizeCommitSha };
|
|
48
56
|
/** Epic and ticket keys accepted by every verb. */
|
|
49
57
|
export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
50
58
|
/** The five verb families. `checkpoint set` is two tokens, one verb. */
|
|
51
|
-
export const CONDUCT_EPIC_VERBS = [
|
|
59
|
+
export const CONDUCT_EPIC_VERBS = [
|
|
60
|
+
"init",
|
|
61
|
+
"status",
|
|
62
|
+
"checkpoint set",
|
|
63
|
+
"finish",
|
|
64
|
+
"spawn",
|
|
65
|
+
"recover",
|
|
66
|
+
"retire",
|
|
67
|
+
"reclaim",
|
|
68
|
+
];
|
|
52
69
|
/** Per-ticket fields `checkpoint set` may assign. */
|
|
53
70
|
const TICKET_FIELDS = [
|
|
54
71
|
"status",
|
|
@@ -67,24 +84,7 @@ const TICKET_FIELDS = [
|
|
|
67
84
|
const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
|
|
68
85
|
/** Build the production dependency set. */
|
|
69
86
|
export function createDefaultConductEpicDeps() {
|
|
70
|
-
const runCommand = (
|
|
71
|
-
execFile(file, args, {
|
|
72
|
-
cwd: options?.cwd,
|
|
73
|
-
// Git porcelain output for a many-worktree checkout can be large.
|
|
74
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
75
|
-
encoding: "utf-8",
|
|
76
|
-
timeout: options?.timeoutMs,
|
|
77
|
-
// Explicit: arguments are a list, never a concatenated shell string.
|
|
78
|
-
shell: false,
|
|
79
|
-
}, (error, stdout, stderr) => {
|
|
80
|
-
const code = error?.code;
|
|
81
|
-
resolve({
|
|
82
|
-
stdout: stdout ?? "",
|
|
83
|
-
stderr: stderr ?? "",
|
|
84
|
-
exitCode: typeof code === "number" ? code : error ? 1 : 0,
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
});
|
|
87
|
+
const runCommand = createExecFileRunCommand();
|
|
88
88
|
const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
|
|
89
89
|
const startTicketsDeps = createDefaultStartTicketsDeps();
|
|
90
90
|
return {
|
|
@@ -121,6 +121,7 @@ export function createDefaultConductEpicDeps() {
|
|
|
121
121
|
cwd: process.cwd(),
|
|
122
122
|
pid: process.pid,
|
|
123
123
|
isProcessAlive: isConductEpicLockOwnerAlive,
|
|
124
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
124
125
|
log: (m) => console.log(m),
|
|
125
126
|
errorLog: (m) => console.error(m),
|
|
126
127
|
resolveAccess: resolveConductorBridgeApiAccess,
|
|
@@ -134,12 +135,16 @@ export function createDefaultConductEpicDeps() {
|
|
|
134
135
|
export function getConductEpicUsage() {
|
|
135
136
|
return [
|
|
136
137
|
"Usage:",
|
|
137
|
-
|
|
138
|
+
` npx -y ${MCP_PACKAGE_NAME} conduct-epic <verb> [flags]`,
|
|
138
139
|
"",
|
|
139
140
|
"Verbs:",
|
|
140
141
|
" init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
|
|
141
|
-
" Run the full preflight, create epic/<EPIC> on origin at the
|
|
142
|
-
"
|
|
142
|
+
" Run the full preflight, then create epic/<EPIC> on origin at the commit the",
|
|
143
|
+
" CANONICAL INDEX covers — not the base tip — seed and verify the epic's index",
|
|
144
|
+
" scope at that commit, repoint the indexed branch, write the checkpoint, and",
|
|
145
|
+
" take the lock. --base-branch selects the base whose history is fetched and",
|
|
146
|
+
" recorded; the cut commit is the canonical indexed SHA and is reported",
|
|
147
|
+
" separately. init fails closed when the repository has no successful parse.",
|
|
143
148
|
" Every preflight failure is printed in one pass; nothing is written unless",
|
|
144
149
|
" all of them pass. --dry-run prints the validated plan and writes nothing.",
|
|
145
150
|
"",
|
|
@@ -148,14 +153,17 @@ export function getConductEpicUsage() {
|
|
|
148
153
|
" CI, review, parse, deadline, and lock state. --json is required. A missing",
|
|
149
154
|
" checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
|
|
150
155
|
" sub-object null and is listed in probe_errors; it never fails the command.",
|
|
156
|
+
" `scopes` lists EVERY index scope this repository owns — expired and",
|
|
157
|
+
" reclaiming ones included — so a crashed epic is visible without SQL.",
|
|
151
158
|
"",
|
|
152
159
|
" checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
|
|
153
160
|
" [--checkpoint-path <p>]",
|
|
154
161
|
" Apply ABSOLUTE field values (the caller computes n+1 from status).",
|
|
155
162
|
` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
|
|
156
163
|
` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
|
|
157
|
-
" Repeat --field to write several in ONE atomic mutation
|
|
158
|
-
"
|
|
164
|
+
" Repeat --field to write several in ONE atomic mutation.",
|
|
165
|
+
" parse_requested_at / parse_requested_for_sha are ACCEPTED for older",
|
|
166
|
+
" checkpoints but no longer written: freshness is read from the scope.",
|
|
159
167
|
"",
|
|
160
168
|
" finish <EPIC> [--checkpoint-path <p>] [--json]",
|
|
161
169
|
" Restore the server's indexed base branch (idempotent), release the owned",
|
|
@@ -167,6 +175,26 @@ export function getConductEpicUsage() {
|
|
|
167
175
|
" file's contents, then increment counters.sessions_spawned and append a",
|
|
168
176
|
" journal line. Respawn and conflict budgets are the CALLER's job.",
|
|
169
177
|
"",
|
|
178
|
+
" recover <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
|
|
179
|
+
" Take a NEW ownership generation for a crashed epic's index scope and",
|
|
180
|
+
" record the returned fencing epoch locally. Use this instead of SQL when",
|
|
181
|
+
" `status` shows a scope whose lease expired. Defaults to the epic's own",
|
|
182
|
+
" scope; --scope targets another one (e.g. when the checkpoint is gone).",
|
|
183
|
+
"",
|
|
184
|
+
" retire <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
|
|
185
|
+
" Start the scope's retention clock. Deletes NOTHING — the scope stays",
|
|
186
|
+
" readable for post-mortem for the whole retention window. Idempotent.",
|
|
187
|
+
" `finish` does this for you; this verb is for retiring without finishing.",
|
|
188
|
+
"",
|
|
189
|
+
" reclaim <EPIC> [--scope <id>] [--override-retention] [--checkpoint-path <p>] [--json]",
|
|
190
|
+
" Ask the server to schedule the scope's teardown: three Pinecone",
|
|
191
|
+
" namespaces, six parse-table slices, three config rows, and a retained",
|
|
192
|
+
" tombstone. Returns as soon as it is SCHEDULED; watch `status` for the",
|
|
193
|
+
" result. --override-retention waives only the still-valid-lease and",
|
|
194
|
+
" unelapsed-retention waits — an active parse, a held parse lock, a live",
|
|
195
|
+
" automation run, or a live epic run still refuse. There is no raw",
|
|
196
|
+
" deletion mode.",
|
|
197
|
+
"",
|
|
170
198
|
"Common:",
|
|
171
199
|
" -h, --help Show this help",
|
|
172
200
|
"",
|
|
@@ -189,6 +217,9 @@ const VERB_FLAGS = {
|
|
|
189
217
|
"checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
|
|
190
218
|
finish: ["--checkpoint-path", "--json"],
|
|
191
219
|
spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
|
|
220
|
+
recover: ["--scope", "--checkpoint-path", "--json"],
|
|
221
|
+
retire: ["--scope", "--checkpoint-path", "--json"],
|
|
222
|
+
reclaim: ["--scope", "--override-retention", "--checkpoint-path", "--json"],
|
|
192
223
|
};
|
|
193
224
|
/**
|
|
194
225
|
* Parse and fully validate argv BEFORE any I/O.
|
|
@@ -214,7 +245,13 @@ export function parseConductEpicArgs(argv) {
|
|
|
214
245
|
verb = "checkpoint-set";
|
|
215
246
|
rest = argv.slice(2);
|
|
216
247
|
}
|
|
217
|
-
else if (argv[0] === "init" ||
|
|
248
|
+
else if (argv[0] === "init" ||
|
|
249
|
+
argv[0] === "status" ||
|
|
250
|
+
argv[0] === "finish" ||
|
|
251
|
+
argv[0] === "spawn" ||
|
|
252
|
+
argv[0] === "recover" ||
|
|
253
|
+
argv[0] === "retire" ||
|
|
254
|
+
argv[0] === "reclaim") {
|
|
218
255
|
verb = argv[0];
|
|
219
256
|
rest = argv.slice(1);
|
|
220
257
|
}
|
|
@@ -229,6 +266,7 @@ export function parseConductEpicArgs(argv) {
|
|
|
229
266
|
fields: [],
|
|
230
267
|
dryRun: false,
|
|
231
268
|
json: false,
|
|
269
|
+
overrideRetention: false,
|
|
232
270
|
};
|
|
233
271
|
const seen = new Set();
|
|
234
272
|
let epicKey;
|
|
@@ -257,6 +295,9 @@ export function parseConductEpicArgs(argv) {
|
|
|
257
295
|
case "--json":
|
|
258
296
|
options.json = true;
|
|
259
297
|
break;
|
|
298
|
+
case "--override-retention":
|
|
299
|
+
options.overrideRetention = true;
|
|
300
|
+
break;
|
|
260
301
|
case "--field": {
|
|
261
302
|
const name = rest[i + 1];
|
|
262
303
|
const value = rest[i + 2];
|
|
@@ -346,6 +387,16 @@ function assignFlagValue(options, flag, value) {
|
|
|
346
387
|
case "--journal":
|
|
347
388
|
options.journal = value;
|
|
348
389
|
return null;
|
|
390
|
+
case "--scope": {
|
|
391
|
+
// Shape-validated here, before any I/O: a server-minted scope id is a uuid4
|
|
392
|
+
// hex. Refusing a malformed value at the boundary means a typo never becomes
|
|
393
|
+
// an authenticated request naming something arbitrary.
|
|
394
|
+
if (!/^[0-9a-f]{32}$/.test(value)) {
|
|
395
|
+
return `Invalid --scope value '${value}'. Expected a 32-character index-scope id.`;
|
|
396
|
+
}
|
|
397
|
+
options.scope = value;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
349
400
|
default:
|
|
350
401
|
return `Unknown flag '${flag}'.`;
|
|
351
402
|
}
|
|
@@ -490,20 +541,16 @@ async function resolveAccess(deps) {
|
|
|
490
541
|
}
|
|
491
542
|
/** Run `git` with list args in the repository working directory. */
|
|
492
543
|
function git(deps, args) {
|
|
493
|
-
return
|
|
494
|
-
}
|
|
495
|
-
/** The single trimmed line a `git rev-parse`-style command produced, or null. */
|
|
496
|
-
function firstLine(result) {
|
|
497
|
-
const value = result.stdout.split("\n")[0]?.trim() ?? "";
|
|
498
|
-
return value.length === 0 ? null : value;
|
|
544
|
+
return runGit(cutProtocolDeps(deps), args);
|
|
499
545
|
}
|
|
500
|
-
/** The
|
|
501
|
-
function
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
546
|
+
/** The strict subset of the pilot's deps the shared cut protocol consumes. */
|
|
547
|
+
function cutProtocolDeps(deps) {
|
|
548
|
+
return {
|
|
549
|
+
runCommand: deps.runCommand,
|
|
550
|
+
cwd: deps.cwd,
|
|
551
|
+
fetchImpl: deps.fetchImpl,
|
|
552
|
+
errorLog: deps.errorLog,
|
|
553
|
+
};
|
|
507
554
|
}
|
|
508
555
|
function isRecord(value) {
|
|
509
556
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -570,7 +617,8 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
570
617
|
failures.push(accessResult.error);
|
|
571
618
|
let baseBranch = options.baseBranch ?? null;
|
|
572
619
|
let baseSha = null;
|
|
573
|
-
let
|
|
620
|
+
let cutCommitSha = null;
|
|
621
|
+
let epicBranchAlreadyAtCut = false;
|
|
574
622
|
if (access !== null) {
|
|
575
623
|
// (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
|
|
576
624
|
const readiness = await getConductorReadiness(access, deps.fetchImpl);
|
|
@@ -636,22 +684,34 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
636
684
|
else if (runState.status !== 404) {
|
|
637
685
|
failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
|
|
638
686
|
}
|
|
639
|
-
// (10)
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
687
|
+
// (10) BAPI-847: there is no repository-wide index-branch override to check
|
|
688
|
+
// any more. An epic no longer takes the repository's index away from anyone —
|
|
689
|
+
// it gets its OWN index scope — so two epics running at once is an ordinary
|
|
690
|
+
// state rather than a conflict a preflight has to detect. The default-base
|
|
691
|
+
// resolution below therefore never reads a stored "original" branch: nothing
|
|
692
|
+
// was ever repointed, so the repository's configured base IS the original.
|
|
693
|
+
// (11) The canonical index must have a usable commit (BAPI-843). This is the
|
|
694
|
+
// check that inverts the cut order: without a `succeeded` canonical parse
|
|
695
|
+
// publishing a commit, there is no commit to cut at, and cutting at the base
|
|
696
|
+
// tip instead is exactly the behavior this replaces. The failure text names
|
|
697
|
+
// the fix an operator can actually perform.
|
|
698
|
+
const parseStatus = await getParseStatus(access, deps.fetchImpl);
|
|
699
|
+
if (!parseStatus.ok) {
|
|
700
|
+
failures.push(`The canonical parse status could not be read: ${parseStatus.error}`);
|
|
701
|
+
}
|
|
702
|
+
else if (parseStatus.value.status !== "succeeded") {
|
|
703
|
+
failures.push(`The canonical index for ${access.repoName} has no successful parse ` +
|
|
704
|
+
`(status: ${String(parseStatus.value.status)}). Parse the repository first.`);
|
|
645
705
|
}
|
|
646
706
|
else {
|
|
647
|
-
const
|
|
648
|
-
if (
|
|
649
|
-
failures.push(`The
|
|
650
|
-
"
|
|
707
|
+
const indexed = normalizeCommitSha(parseStatus.value.indexed_commit_sha);
|
|
708
|
+
if (indexed === null) {
|
|
709
|
+
failures.push(`The canonical index for ${access.repoName} published no commit for its ` +
|
|
710
|
+
"last successful parse, so there is no commit to cut at. " +
|
|
711
|
+
"Parse the repository first.");
|
|
651
712
|
}
|
|
652
|
-
else
|
|
653
|
-
|
|
654
|
-
baseBranch = override.original_base_branch;
|
|
713
|
+
else {
|
|
714
|
+
cutCommitSha = indexed;
|
|
655
715
|
}
|
|
656
716
|
}
|
|
657
717
|
// (8) default base, continued: the configured base branch, then `main`.
|
|
@@ -669,8 +729,9 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
669
729
|
failures.push(`The resolved base branch is unusable: ${branchReason}`);
|
|
670
730
|
}
|
|
671
731
|
else {
|
|
672
|
-
// (8) The base
|
|
673
|
-
//
|
|
732
|
+
// (8) The base is still fetched — local git needs its object history, and the
|
|
733
|
+
// cut commit is almost always reachable from it — but the base tip is NO
|
|
734
|
+
// LONGER the branch source (BAPI-843). It is reported for context only.
|
|
674
735
|
const fetched = await git(deps, ["fetch", "origin", baseBranch]);
|
|
675
736
|
if (fetched.exitCode !== 0) {
|
|
676
737
|
failures.push(`git fetch origin ${baseBranch} failed.`);
|
|
@@ -680,7 +741,28 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
680
741
|
if (baseSha === null) {
|
|
681
742
|
failures.push(`origin/${baseBranch} does not exist after fetching.`);
|
|
682
743
|
}
|
|
683
|
-
|
|
744
|
+
if (cutCommitSha !== null) {
|
|
745
|
+
// (12) The EXACT cut object must be resolvable locally, because `init`
|
|
746
|
+
// pushes it by SHA. The base fetch usually brings it along; when it did
|
|
747
|
+
// not — the index covers a commit that is no longer an ancestor of the
|
|
748
|
+
// base tip — one targeted, NON-MUTATING fetch of that SHA is attempted (it
|
|
749
|
+
// updates no ref, creates no branch, and checks nothing out). Failing here
|
|
750
|
+
// is deliberate: it happens before the cut protocol, so nothing has been
|
|
751
|
+
// held, pushed, or recorded.
|
|
752
|
+
const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
|
|
753
|
+
if (present.exitCode !== 0) {
|
|
754
|
+
await git(deps, ["fetch", "origin", cutCommitSha]);
|
|
755
|
+
const retry = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
|
|
756
|
+
if (retry.exitCode !== 0) {
|
|
757
|
+
failures.push(`The canonical indexed commit ${cutCommitSha} could not be resolved locally ` +
|
|
758
|
+
`even after fetching it from origin. Fetch it manually, or re-parse ${access?.repoName ?? "the repository"}.`);
|
|
759
|
+
cutCommitSha = null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
|
|
764
|
+
// canonical indexed commit. An epic branch sitting at ANY other commit still
|
|
765
|
+
// fails closed — including the base tip, which is no longer special.
|
|
684
766
|
const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
|
|
685
767
|
if (existing.exitCode !== 0) {
|
|
686
768
|
failures.push(`git ls-remote could not read origin/${epicBranch}.`);
|
|
@@ -688,11 +770,12 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
688
770
|
else {
|
|
689
771
|
const existingSha = lsRemoteSha(existing);
|
|
690
772
|
if (existingSha !== null) {
|
|
691
|
-
if (
|
|
692
|
-
|
|
773
|
+
if (cutCommitSha !== null && existingSha === cutCommitSha) {
|
|
774
|
+
epicBranchAlreadyAtCut = true;
|
|
693
775
|
}
|
|
694
776
|
else {
|
|
695
|
-
failures.push(`origin/${epicBranch} already exists at
|
|
777
|
+
failures.push(`origin/${epicBranch} already exists at ${existingSha}, which is not the ` +
|
|
778
|
+
`canonical indexed commit${cutCommitSha ? ` ${cutCommitSha}` : ""}. ` +
|
|
696
779
|
"Delete it or finish the previous run before re-initializing.");
|
|
697
780
|
}
|
|
698
781
|
}
|
|
@@ -717,7 +800,8 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
717
800
|
access,
|
|
718
801
|
baseBranch,
|
|
719
802
|
baseSha,
|
|
720
|
-
|
|
803
|
+
cutCommitSha,
|
|
804
|
+
epicBranchAlreadyAtCut,
|
|
721
805
|
pendingSupervisorConfig,
|
|
722
806
|
};
|
|
723
807
|
}
|
|
@@ -743,6 +827,80 @@ function requiredCheckNamesFromResolve(value) {
|
|
|
743
827
|
}
|
|
744
828
|
return names;
|
|
745
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* Drive the scope from a recorded cut to `ready`, or report why it did not.
|
|
832
|
+
*
|
|
833
|
+
* Schedules the bootstrap (seed + verifying parse) and then POLLS the control
|
|
834
|
+
* plane, because readiness is a server-side fact this CLI observes rather than
|
|
835
|
+
* one it can conclude from its own request succeeding.
|
|
836
|
+
*
|
|
837
|
+
* Each terminal state maps to its own operator instruction, and the distinction
|
|
838
|
+
* matters: `canonical_index_advanced` means re-run `init` (the cut protocol
|
|
839
|
+
* re-drives at the newer commit), while a verification failure means the seed
|
|
840
|
+
* itself is wrong and re-cutting would just reproduce it.
|
|
841
|
+
*/
|
|
842
|
+
async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
|
|
843
|
+
const scheduled = await bootstrapIndexScope(access, { scopeId }, deps.fetchImpl);
|
|
844
|
+
if (!scheduled.ok) {
|
|
845
|
+
return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
|
|
846
|
+
}
|
|
847
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
848
|
+
let lastState = "unknown";
|
|
849
|
+
for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
|
|
850
|
+
await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
|
|
851
|
+
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
852
|
+
if (!status.ok) {
|
|
853
|
+
// A transient read failure is not a verdict: keep polling and let the
|
|
854
|
+
// bound below be the thing that gives up.
|
|
855
|
+
lastState = `unreadable (${status.error})`;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
lastState = status.value.lifecycle_state;
|
|
859
|
+
if (status.value.lifecycle_state === "ready") {
|
|
860
|
+
if (status.value.indexed_commit_sha !== null &&
|
|
861
|
+
status.value.indexed_commit_sha === status.value.cut_commit_sha) {
|
|
862
|
+
return { ok: true, failures: [] };
|
|
863
|
+
}
|
|
864
|
+
// `ready` is the server's verdict, and the server only promotes a scope
|
|
865
|
+
// whose watermark matches. Disagreeing here would mean the control plane
|
|
866
|
+
// contradicted itself, which is worth refusing rather than proceeding.
|
|
867
|
+
return {
|
|
868
|
+
ok: false,
|
|
869
|
+
failures: [
|
|
870
|
+
`Index scope ${scopeId} reports ready but its indexed commit ` +
|
|
871
|
+
`(${status.value.indexed_commit_sha ?? "none"}) is not the cut commit ` +
|
|
872
|
+
`(${status.value.cut_commit_sha ?? "none"}).`,
|
|
873
|
+
],
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
if (status.value.lifecycle_state === "failed") {
|
|
877
|
+
const reason = status.value.last_error ?? "unknown";
|
|
878
|
+
if (reason === "canonical_index_advanced") {
|
|
879
|
+
return {
|
|
880
|
+
ok: false,
|
|
881
|
+
failures: [
|
|
882
|
+
`The canonical index advanced before the seed could run, so the scope was not seeded. ` +
|
|
883
|
+
`Delete origin/${epicBranchFor(options.epicKey)} and re-run init to cut at the newer commit.`,
|
|
884
|
+
],
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
ok: false,
|
|
889
|
+
failures: [
|
|
890
|
+
`Index scope ${scopeId} failed verification (${reason}). ` +
|
|
891
|
+
"The epic branch and its recorded cut are intact; re-run init to re-drive verification.",
|
|
892
|
+
],
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
ok: false,
|
|
898
|
+
failures: [
|
|
899
|
+
`Index scope ${scopeId} did not become ready within the bootstrap window ` +
|
|
900
|
+
`(last observed state: ${lastState}). Re-run init to resume verification.`,
|
|
901
|
+
],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
746
904
|
/**
|
|
747
905
|
* `conduct-epic init` — the only verb that provisions.
|
|
748
906
|
*
|
|
@@ -776,13 +934,19 @@ export async function runConductEpicInit(deps, options) {
|
|
|
776
934
|
});
|
|
777
935
|
}
|
|
778
936
|
const access = preflight.access;
|
|
779
|
-
if (access === null ||
|
|
937
|
+
if (access === null ||
|
|
938
|
+
preflight.baseBranch === null ||
|
|
939
|
+
preflight.baseSha === null ||
|
|
940
|
+
preflight.cutCommitSha === null) {
|
|
780
941
|
// Unreachable: any of these being absent records a failure above. Guarded so
|
|
781
|
-
// a future edit cannot turn a missing precondition into a push
|
|
942
|
+
// a future edit cannot turn a missing precondition into a push — in
|
|
943
|
+
// particular a missing `cutCommitSha`, which would otherwise be a push at
|
|
944
|
+
// `undefined`.
|
|
782
945
|
return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
|
|
783
946
|
epic_key: options.epicKey,
|
|
784
947
|
});
|
|
785
948
|
}
|
|
949
|
+
const cutCommitSha = preflight.cutCommitSha;
|
|
786
950
|
// Starts as the preflight's own lines (which in `--dry-run` already include the
|
|
787
951
|
// would-enable notice) and grows by at most the one success line below.
|
|
788
952
|
const announcements = [...preflight.announcements];
|
|
@@ -791,8 +955,12 @@ export async function runConductEpicInit(deps, options) {
|
|
|
791
955
|
const describePlan = () => [
|
|
792
956
|
`epic: ${options.epicKey}`,
|
|
793
957
|
`repo: ${access.repoName}`,
|
|
958
|
+
// The base branch and the cut commit are reported SEPARATELY and never
|
|
959
|
+
// conflated: the base is context (and the object history git needs), the cut
|
|
960
|
+
// is the commit the epic actually starts from.
|
|
794
961
|
`base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
|
|
795
|
-
`
|
|
962
|
+
`cut: ${cutCommitSha} (canonical indexed commit)`,
|
|
963
|
+
`branch: ${epicBranch}${preflight.epicBranchAlreadyAtCut ? " (already at the cut commit)" : ""}`,
|
|
796
964
|
`tickets: ${options.tickets.join(", ")}`,
|
|
797
965
|
`checkpoint: ${checkpointPath}`,
|
|
798
966
|
...announcements,
|
|
@@ -805,10 +973,11 @@ export async function runConductEpicInit(deps, options) {
|
|
|
805
973
|
epic_branch: epicBranch,
|
|
806
974
|
base_branch: preflight.baseBranch,
|
|
807
975
|
base_sha: preflight.baseSha,
|
|
976
|
+
cut_commit_sha: cutCommitSha,
|
|
808
977
|
tickets: options.tickets,
|
|
809
978
|
checkpoint_path: checkpointPath,
|
|
810
979
|
announcements,
|
|
811
|
-
}, ["Planned (dry run — nothing was pushed, repointed, or written):", ...describePlan()]);
|
|
980
|
+
}, ["Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):", ...describePlan()]);
|
|
812
981
|
}
|
|
813
982
|
// The FIRST durable mutation of the whole verb, deliberately placed here: every
|
|
814
983
|
// preflight check has passed, and nothing has been pushed, repointed, written,
|
|
@@ -824,31 +993,60 @@ export async function runConductEpicInit(deps, options) {
|
|
|
824
993
|
}
|
|
825
994
|
announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
|
|
826
995
|
}
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
996
|
+
// --- The cut protocol (BAPI-843) ----------------------------------------
|
|
997
|
+
//
|
|
998
|
+
// Driven by the SHARED exact-cut module (BAPI-850): `cut/begin`, the re-check
|
|
999
|
+
// of `origin/epic/<EPIC>` under the hold, the exact-SHA push with the
|
|
1000
|
+
// operator's own git, the read-back, `cut/commit`, and the release on every
|
|
1001
|
+
// outcome all happen in `performExactIndexScopeCut`, while the SERVER holds
|
|
1002
|
+
// the canonical repository's parse lock so the commit the index covers cannot
|
|
1003
|
+
// move underneath the ref being created. The pilot creates no `epic_run`, so
|
|
1004
|
+
// it passes no run association.
|
|
1005
|
+
const cutOutcome = await performExactIndexScopeCut(cutProtocolDeps(deps), access, {
|
|
1006
|
+
featureBranch: epicBranch,
|
|
1007
|
+
baseBranch: preflight.baseBranch,
|
|
1008
|
+
candidateCommitSha: cutCommitSha,
|
|
1009
|
+
});
|
|
1010
|
+
if (!cutOutcome.ok) {
|
|
1011
|
+
// `begin` refusals carry the checkpoint path (nothing was leased); every
|
|
1012
|
+
// later refusal names only the epic, exactly as before the extraction.
|
|
1013
|
+
return emitFailure(deps, options.json, cutOutcome.failures, cutOutcome.kind === "begin_refused"
|
|
1014
|
+
? { epic_key: options.epicKey, checkpoint_path: checkpointPath }
|
|
1015
|
+
: { epic_key: options.epicKey });
|
|
839
1016
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1017
|
+
// A successful outcome IS the recorded cut: the shared module returns `ok`
|
|
1018
|
+
// only after `cut/commit` accepted the ref, so there is no unrecorded-cut
|
|
1019
|
+
// state to guard against here.
|
|
1020
|
+
const cut = cutOutcome.lease;
|
|
1021
|
+
// --- Seed + verify, only after the hold is released ----------------------
|
|
1022
|
+
const scopeReady = await driveIndexScopeBootstrap(deps, access, cut.scope_id, options);
|
|
1023
|
+
if (!scopeReady.ok) {
|
|
1024
|
+
return emitFailure(deps, options.json, scopeReady.failures, {
|
|
843
1025
|
epic_key: options.epicKey,
|
|
1026
|
+
scope_id: cut.scope_id,
|
|
1027
|
+
checkpoint_path: checkpointPath,
|
|
844
1028
|
});
|
|
845
1029
|
}
|
|
1030
|
+
announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
|
|
1031
|
+
// BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
|
|
1032
|
+
// branch here, which is the defect this epic exists to remove — a
|
|
1033
|
+
// repository-wide mutation that every unrelated run then resolved through. The
|
|
1034
|
+
// scope provisioned above already carries the epic branch on its own shadow
|
|
1035
|
+
// config row, so the canonical repository's configuration is never touched and
|
|
1036
|
+
// nothing has to be restored later.
|
|
1037
|
+
//
|
|
1038
|
+
// The checkpoint is still written LAST: it is only correct once the scope has
|
|
1039
|
+
// proven its own coverage.
|
|
846
1040
|
const request = lockRequest(deps);
|
|
847
1041
|
const checkpoint = createInitialConductEpicCheckpoint({
|
|
848
1042
|
epicKey: options.epicKey,
|
|
849
1043
|
repoName: access.repoName,
|
|
850
1044
|
epicBranch,
|
|
851
1045
|
baseBranchOriginal: preflight.baseBranch,
|
|
1046
|
+
// BAPI-844: the server-minted scope from THIS init's cut, recorded so every
|
|
1047
|
+
// later `spawn` declares the scope the epic was actually cut against. It is
|
|
1048
|
+
// stored exactly as returned; nothing local mints, edits, or defaults it.
|
|
1049
|
+
indexScopeId: cut.scope_id,
|
|
852
1050
|
ticketKeys: options.tickets,
|
|
853
1051
|
now: deps.now().toISOString(),
|
|
854
1052
|
lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
|
|
@@ -874,10 +1072,11 @@ export async function runConductEpicInit(deps, options) {
|
|
|
874
1072
|
epic_branch: epicBranch,
|
|
875
1073
|
base_branch: preflight.baseBranch,
|
|
876
1074
|
base_sha: preflight.baseSha,
|
|
1075
|
+
cut_commit_sha: cutCommitSha,
|
|
1076
|
+
scope_id: cut.scope_id,
|
|
877
1077
|
tickets: options.tickets,
|
|
878
1078
|
checkpoint_path: checkpointPath,
|
|
879
1079
|
lock_path: resolveConductEpicLockPath(checkpointPath),
|
|
880
|
-
index_repointed: true,
|
|
881
1080
|
announcements,
|
|
882
1081
|
}, ["Initialized:", ...describePlan()]);
|
|
883
1082
|
}
|
|
@@ -1058,6 +1257,102 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1058
1257
|
parse = normalizeParseStatus(parseStatus.value);
|
|
1059
1258
|
}
|
|
1060
1259
|
}
|
|
1260
|
+
// --- scope (BAPI-845) ----------------------------------------------------
|
|
1261
|
+
// The pilot's freshness evidence, asked DIRECTLY. `parse` above is
|
|
1262
|
+
// repository-level and says nothing about whether *this* merge was indexed,
|
|
1263
|
+
// which is why Row 5 used to reconstruct causality from a request timestamp and
|
|
1264
|
+
// a head SHA. This probe replaces that inference with the scope's own answer:
|
|
1265
|
+
// its lifecycle, both watermarks, and a bounded freshness verdict.
|
|
1266
|
+
//
|
|
1267
|
+
// Absent only when the epic declares no scope — a pilot epic cut before
|
|
1268
|
+
// BAPI-843, or a non-scope run. That is a calm `null` with no probe error,
|
|
1269
|
+
// because there is nothing to report rather than something we failed to read.
|
|
1270
|
+
let scope = null;
|
|
1271
|
+
const declaredScopeId = typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0
|
|
1272
|
+
? checkpoint.index_scope_id
|
|
1273
|
+
: null;
|
|
1274
|
+
if (access !== null && declaredScopeId !== null) {
|
|
1275
|
+
const scopeStatus = await getIndexScopeStatus(access, declaredScopeId, deps.fetchImpl);
|
|
1276
|
+
if (!scopeStatus.ok) {
|
|
1277
|
+
probeErrors.push({ probe: "scope", reason: scopeStatus.error });
|
|
1278
|
+
// An unread scope is reported as explicitly `unavailable` rather than left
|
|
1279
|
+
// null: a null could be read as "no scope declared", and that reading would
|
|
1280
|
+
// let the loop proceed past a freshness question it never got an answer to.
|
|
1281
|
+
scope = {
|
|
1282
|
+
scope_id: declaredScopeId,
|
|
1283
|
+
lifecycle_state: null,
|
|
1284
|
+
freshness_status: "unavailable",
|
|
1285
|
+
blocked_reason: null,
|
|
1286
|
+
required_commit_sha: null,
|
|
1287
|
+
indexed_commit_sha: null,
|
|
1288
|
+
last_error: null,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
scope = {
|
|
1293
|
+
scope_id: scopeStatus.value.scope_id,
|
|
1294
|
+
lifecycle_state: scopeStatus.value.lifecycle_state,
|
|
1295
|
+
// Fail closed: the client already narrowed an unrecognized value to null,
|
|
1296
|
+
// and null here means "not fresh", never "fine".
|
|
1297
|
+
freshness_status: scopeStatus.value.freshness_status ?? "unavailable",
|
|
1298
|
+
blocked_reason: scopeStatus.value.blocked_reason,
|
|
1299
|
+
required_commit_sha: scopeStatus.value.required_commit_sha,
|
|
1300
|
+
indexed_commit_sha: scopeStatus.value.indexed_commit_sha,
|
|
1301
|
+
last_error: scopeStatus.value.last_error,
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
|
|
1306
|
+
//
|
|
1307
|
+
// TWO distinct jobs, both belonging here rather than in a daemon:
|
|
1308
|
+
//
|
|
1309
|
+
// 1. The LISTING makes a crashed epic's scope discoverable. `scope` above
|
|
1310
|
+
// answers "is MY index fresh?"; this answers "what index scopes exist, and
|
|
1311
|
+
// which of them is stranded?" — including expired, reclaiming, and reclaimed
|
|
1312
|
+
// ones, which is what makes a stale scope actionable without SQL.
|
|
1313
|
+
// 2. The HEARTBEAT renews this epic's lease. The pilot has no long-lived local
|
|
1314
|
+
// process — `/loop 5m /conduct-epic` re-invokes this command on a timer, so
|
|
1315
|
+
// the tick loop IS the heartbeat owner, and its cadence matches the default
|
|
1316
|
+
// heartbeat interval. Beating from here means ownership is renewed exactly
|
|
1317
|
+
// while a conductor is actively driving the epic, and stops the moment it
|
|
1318
|
+
// stops ticking, with no untracked daemon and no shutdown contract to get
|
|
1319
|
+
// wrong.
|
|
1320
|
+
//
|
|
1321
|
+
// Both are probes: a failure lands in `probe_errors` and never fails `status`.
|
|
1322
|
+
let scopes = [];
|
|
1323
|
+
let retentionSeconds = null;
|
|
1324
|
+
let nextLeaseEpoch = checkpoint.index_scope_lease_epoch;
|
|
1325
|
+
if (access !== null) {
|
|
1326
|
+
const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
|
|
1327
|
+
if (!listing.ok) {
|
|
1328
|
+
probeErrors.push({ probe: "scopes", reason: listing.error });
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
scopes = listing.value.scopes;
|
|
1332
|
+
retentionSeconds = listing.value.retention_seconds;
|
|
1333
|
+
}
|
|
1334
|
+
if (declaredScopeId !== null) {
|
|
1335
|
+
// The epoch comes from the SERVER's listing when it is readable, and from
|
|
1336
|
+
// the checkpoint only as a fallback. Preferring the server is what lets a
|
|
1337
|
+
// conductor keep beating after an operator `recover` superseded its
|
|
1338
|
+
// generation — the alternative is a healthy conductor permanently fenced by
|
|
1339
|
+
// a stale local number.
|
|
1340
|
+
const entry = scopes.find((scope) => scope.scope_id === declaredScopeId);
|
|
1341
|
+
const epoch = entry?.lease_epoch ?? checkpoint.index_scope_lease_epoch;
|
|
1342
|
+
if (epoch !== null && entry?.recoverable !== false) {
|
|
1343
|
+
const beat = await heartbeatIndexScope(access, { scopeId: declaredScopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
1344
|
+
if (beat.ok) {
|
|
1345
|
+
nextLeaseEpoch = beat.value.lease_epoch;
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
// A rejected beat means fenced or retired. Recorded as a probe error so
|
|
1349
|
+
// the loop SEES it, not swallowed — but never fatal, because `status`
|
|
1350
|
+
// is also how an operator finds out they were fenced.
|
|
1351
|
+
probeErrors.push({ probe: "scope_heartbeat", reason: beat.error });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1061
1356
|
// --- lock (inspected, NEVER acquired) ------------------------------------
|
|
1062
1357
|
const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1063
1358
|
const lock = {
|
|
@@ -1070,9 +1365,14 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1070
1365
|
? false
|
|
1071
1366
|
: null,
|
|
1072
1367
|
};
|
|
1073
|
-
// --- the
|
|
1368
|
+
// --- the five permitted writes -------------------------------------------
|
|
1369
|
+
// BAPI-846 added the fifth: the scope's fencing epoch, refreshed from the
|
|
1370
|
+
// server's authoritative answer. It rides in the SAME atomic write as the other
|
|
1371
|
+
// four rather than in a second one, so a tick either records everything it
|
|
1372
|
+
// observed or nothing.
|
|
1074
1373
|
let lastSeenHead = ticket?.last_seen_head ?? null;
|
|
1075
1374
|
let lastStateChangeAt = ticket?.last_state_change_at ?? null;
|
|
1375
|
+
const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
|
|
1076
1376
|
if (ticket !== null) {
|
|
1077
1377
|
const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
|
|
1078
1378
|
const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
|
|
@@ -1094,6 +1394,10 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1094
1394
|
next.ci_last_poll = ci.ci_last_poll;
|
|
1095
1395
|
dirty = true;
|
|
1096
1396
|
}
|
|
1397
|
+
if (leaseEpochChanged) {
|
|
1398
|
+
next.index_scope_lease_epoch = nextLeaseEpoch;
|
|
1399
|
+
dirty = true;
|
|
1400
|
+
}
|
|
1097
1401
|
if (dirty) {
|
|
1098
1402
|
next.updated_at = now.toISOString();
|
|
1099
1403
|
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
@@ -1128,11 +1432,104 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1128
1432
|
hard_seconds: checkpoint.deadlines.hard_seconds,
|
|
1129
1433
|
elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
|
|
1130
1434
|
},
|
|
1435
|
+
scope,
|
|
1436
|
+
scope_lease_epoch: nextLeaseEpoch,
|
|
1437
|
+
retention_seconds: retentionSeconds,
|
|
1438
|
+
scopes,
|
|
1131
1439
|
lock,
|
|
1132
1440
|
needs_human: checkpoint.needs_human,
|
|
1133
1441
|
probe_errors: probeErrors,
|
|
1134
1442
|
};
|
|
1135
|
-
return emitSuccess(deps, options.json, payload
|
|
1443
|
+
return emitSuccess(deps, options.json, payload, [
|
|
1444
|
+
...renderScopeFreshnessLines(scope),
|
|
1445
|
+
...renderStrandedScopeLines(scopes, declaredScopeId),
|
|
1446
|
+
]);
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Render the compact freshness unit for the human-readable `status` output.
|
|
1450
|
+
*
|
|
1451
|
+
* There is no dashboard for an index scope and this deliberately does not invent
|
|
1452
|
+
* one — the unit lives on the status surface that already exists. Its shape is
|
|
1453
|
+
* fixed by what an operator needs to decide, in that order:
|
|
1454
|
+
*
|
|
1455
|
+
* 1. **What is happening to the ticket**, first and in plain language. "Waiting
|
|
1456
|
+
* for index refresh" is the answer to the question actually being asked; a
|
|
1457
|
+
* lifecycle name is not.
|
|
1458
|
+
* 2. **The lifecycle**, then the two commits on SEPARATE, SEPARATELY LABELLED
|
|
1459
|
+
* lines. Printing them together, or printing only one, is what let "the commit
|
|
1460
|
+
* we must index" read as "the commit we indexed".
|
|
1461
|
+
* 3. **The refusal, spelled out**, when there is one. A controlled token is
|
|
1462
|
+
* precise but not self-explaining, so each is given a sentence — and each
|
|
1463
|
+
* sentence distinguishes it from a plain parse failure.
|
|
1464
|
+
*
|
|
1465
|
+
* Returns an empty array when the epic declares no scope, so an epic without one
|
|
1466
|
+
* prints exactly what it printed before.
|
|
1467
|
+
*/
|
|
1468
|
+
/**
|
|
1469
|
+
* Render the STRANDED-SCOPE warning unit for `status` (BAPI-846).
|
|
1470
|
+
*
|
|
1471
|
+
* Deliberately narrow: it names only scopes that are not this epic's and are not
|
|
1472
|
+
* live — the crashed-epic case an operator can act on — and it says nothing at
|
|
1473
|
+
* all when there are none. A full inventory belongs in the JSON payload; the
|
|
1474
|
+
* human output exists to make one specific problem impossible to miss, and a
|
|
1475
|
+
* block that prints on every healthy tick is a block operators stop reading.
|
|
1476
|
+
*
|
|
1477
|
+
* `reclaimed` scopes are omitted: a tombstone is a completed outcome, not
|
|
1478
|
+
* something to act on.
|
|
1479
|
+
*/
|
|
1480
|
+
function renderStrandedScopeLines(scopes, ownScopeId) {
|
|
1481
|
+
const stranded = scopes.filter((scope) => scope.scope_id !== ownScopeId &&
|
|
1482
|
+
scope.lifecycle_state !== "reclaimed" &&
|
|
1483
|
+
!scope.lease_valid);
|
|
1484
|
+
if (stranded.length === 0)
|
|
1485
|
+
return [];
|
|
1486
|
+
const lines = [
|
|
1487
|
+
`${stranded.length} index scope(s) in this repository have no live lease:`,
|
|
1488
|
+
];
|
|
1489
|
+
for (const scope of stranded) {
|
|
1490
|
+
const action = scope.recoverable
|
|
1491
|
+
? "recoverable — `conduct-epic recover <EPIC> --scope " + scope.scope_id + "`"
|
|
1492
|
+
: scope.retention_elapsed
|
|
1493
|
+
? "past retention — the sweep will reclaim it"
|
|
1494
|
+
: `retained until ${scope.retention_deadline ?? "an unknown deadline"}`;
|
|
1495
|
+
lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
|
|
1496
|
+
`branch=${scope.feature_branch ?? "unknown"} ${action}`);
|
|
1497
|
+
if (scope.blockers.length > 0) {
|
|
1498
|
+
lines.push(` blocked by: ${scope.blockers.join(", ")}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return lines;
|
|
1502
|
+
}
|
|
1503
|
+
function renderScopeFreshnessLines(scope) {
|
|
1504
|
+
if (scope === null)
|
|
1505
|
+
return [];
|
|
1506
|
+
const headline = {
|
|
1507
|
+
fresh: "Index is fresh for this epic.",
|
|
1508
|
+
pending: "Waiting for index refresh.",
|
|
1509
|
+
blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
|
|
1510
|
+
failed: "Index generation FAILED for this scope.",
|
|
1511
|
+
unavailable: "Index freshness is unavailable — treat as not fresh.",
|
|
1512
|
+
};
|
|
1513
|
+
const refusal = {
|
|
1514
|
+
advance_blocked_base_merge: "the base branch was merged forward into the epic branch, which would move the branch's pinned cut point",
|
|
1515
|
+
advance_blocked_unexpected_parent: "the merge commit does not descend directly from the head this scope pinned, so it is not a worker merge",
|
|
1516
|
+
advance_blocked_history_changed: "the pinned head is gone from the branch's history — a force-push or rewrite",
|
|
1517
|
+
advance_blocked_unverifiable: "the advance could not be verified at all, and doubt blocks rather than indexes",
|
|
1518
|
+
};
|
|
1519
|
+
const lines = [
|
|
1520
|
+
headline[scope.freshness_status] ?? "Index freshness is unknown — treat as not fresh.",
|
|
1521
|
+
` lifecycle: ${scope.lifecycle_state ?? "unknown"}`,
|
|
1522
|
+
` Required commit: ${scope.required_commit_sha ?? "none"}`,
|
|
1523
|
+
` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`,
|
|
1524
|
+
];
|
|
1525
|
+
if (scope.blocked_reason !== null) {
|
|
1526
|
+
lines.push(` Reason: ${scope.blocked_reason} — ${refusal[scope.blocked_reason] ?? "the server refused this branch advance"}`);
|
|
1527
|
+
lines.push(" A human must resolve the branch before the epic can continue.");
|
|
1528
|
+
}
|
|
1529
|
+
else if (scope.freshness_status === "failed" && scope.last_error !== null) {
|
|
1530
|
+
lines.push(` Failure category: ${scope.last_error}`);
|
|
1531
|
+
}
|
|
1532
|
+
return lines;
|
|
1136
1533
|
}
|
|
1137
1534
|
/**
|
|
1138
1535
|
* Project one checkpoint ticket into the published `ticket` facts.
|
|
@@ -1321,7 +1718,6 @@ export function normalizeParseStatus(value) {
|
|
|
1321
1718
|
terminal: status === "succeeded" || status === "failed",
|
|
1322
1719
|
started_at: optionalText("started_at"),
|
|
1323
1720
|
finished_at: optionalText("finished_at"),
|
|
1324
|
-
index_branch_override: optionalText("index_branch_override"),
|
|
1325
1721
|
};
|
|
1326
1722
|
}
|
|
1327
1723
|
// ---------------------------------------------------------------------------
|
|
@@ -1620,12 +2016,27 @@ export async function runConductEpicSpawn(deps, options) {
|
|
|
1620
2016
|
catch {
|
|
1621
2017
|
return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
|
|
1622
2018
|
}
|
|
2019
|
+
// BAPI-844: the scope this epic was cut against, read ONLY from the durable
|
|
2020
|
+
// server-returned value on the checkpoint. `process.env.BAPI_INDEX_SCOPE` is
|
|
2021
|
+
// deliberately not consulted — an operator's shell is not the epic's routing
|
|
2022
|
+
// decision, and a pilot ticket has no server-side membership to fall back on,
|
|
2023
|
+
// so an ambient value would silently route this worker's research somewhere
|
|
2024
|
+
// nobody chose. A malformed recorded value stops the spawn with the fixed
|
|
2025
|
+
// configuration error rather than launching an unscoped (canonical) worker.
|
|
2026
|
+
let indexScope;
|
|
2027
|
+
try {
|
|
2028
|
+
indexScope = validateOptionalIndexScope(checkpoint.index_scope_id);
|
|
2029
|
+
}
|
|
2030
|
+
catch {
|
|
2031
|
+
return emitFailure(deps, options.json, [INDEX_SCOPE_CONFIGURATION_ERROR]);
|
|
2032
|
+
}
|
|
1623
2033
|
const spawned = await spawnConductEpicAgentTab({
|
|
1624
2034
|
ticketKey,
|
|
1625
2035
|
worktreePath: found.path,
|
|
1626
2036
|
prompt,
|
|
1627
2037
|
agent: options.agent,
|
|
1628
2038
|
platform: deps.platform,
|
|
2039
|
+
...(indexScope === undefined ? {} : { indexScope }),
|
|
1629
2040
|
}, deps.spawnTab);
|
|
1630
2041
|
if (!spawned.ok)
|
|
1631
2042
|
return emitFailure(deps, options.json, [spawned.error]);
|
|
@@ -1667,14 +2078,14 @@ export async function runConductEpicSpawn(deps, options) {
|
|
|
1667
2078
|
// finish
|
|
1668
2079
|
// ---------------------------------------------------------------------------
|
|
1669
2080
|
/**
|
|
1670
|
-
* `conduct-epic finish` —
|
|
2081
|
+
* `conduct-epic finish` — retire the epic's index scope and wind down.
|
|
1671
2082
|
*
|
|
1672
|
-
*
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
2083
|
+
* BAPI-847: there is nothing to RESTORE. `init` no longer repoints the
|
|
2084
|
+
* repository's `base_branch`, so `finish` has no repository-wide configuration
|
|
2085
|
+
* to put back — it retires the scope the epic was cut against and reports that
|
|
2086
|
+
* scope's lifecycle outcome and nothing else. The checkpoint's
|
|
2087
|
+
* `base_branch_original` remains display-only: it records which base the epic was
|
|
2088
|
+
* cut from, never a value this verb writes anywhere.
|
|
1678
2089
|
*/
|
|
1679
2090
|
export async function runConductEpicFinish(deps, options) {
|
|
1680
2091
|
const accessProbe = await resolveAccess(deps);
|
|
@@ -1693,19 +2104,44 @@ export async function runConductEpicFinish(deps, options) {
|
|
|
1693
2104
|
if (!lock.acquired) {
|
|
1694
2105
|
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
1695
2106
|
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2107
|
+
// BAPI-846: `finish` RETIRES the epic's index scope; it never deletes it. The
|
|
2108
|
+
// scope keeps every Postgres row and every Pinecone namespace and stays
|
|
2109
|
+
// readable for post-mortem for the whole retention window — deletion is always
|
|
2110
|
+
// the scheduled sweep's or an explicit `reclaim`'s.
|
|
2111
|
+
//
|
|
2112
|
+
// Retirement runs INSIDE the lock, before it is released, so a concurrent
|
|
2113
|
+
// `finish` cannot interleave with it. It calls the retire API and never a
|
|
2114
|
+
// namespace or database deletion, and enough local state is retained (the
|
|
2115
|
+
// checkpoint keeps `index_scope_id` and the epoch) that a failed retirement can
|
|
2116
|
+
// simply be retried with `conduct-epic retire`.
|
|
2117
|
+
let scopeRetired = null;
|
|
2118
|
+
let scopeRetirementError = null;
|
|
2119
|
+
if (typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0) {
|
|
2120
|
+
const retirement = await retireScopeWithEpoch(deps, options, access, {
|
|
2121
|
+
scopeId: checkpoint.index_scope_id,
|
|
2122
|
+
checkpoint,
|
|
2123
|
+
leaseEpoch: checkpoint.index_scope_lease_epoch,
|
|
2124
|
+
});
|
|
2125
|
+
scopeRetired = retirement.ok;
|
|
2126
|
+
if (!retirement.ok)
|
|
2127
|
+
scopeRetirementError = retirement.reason;
|
|
1700
2128
|
}
|
|
1701
2129
|
await releaseAcquired(lock);
|
|
2130
|
+
// A failed retirement does NOT fail `finish`: the lock is already released and
|
|
2131
|
+
// every other wind-down step has happened, so reporting failure would invite a
|
|
2132
|
+
// re-run that redoes work already done. It is surfaced instead, with the one
|
|
2133
|
+
// command that fixes it — and the scope enters retention on lease expiry
|
|
2134
|
+
// regardless.
|
|
2135
|
+
if (scopeRetirementError !== null) {
|
|
2136
|
+
deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
|
|
2137
|
+
`Retry with \`conduct-epic retire ${checkpoint.epic_key}\`.`);
|
|
2138
|
+
}
|
|
1702
2139
|
const summary = {
|
|
1703
2140
|
ok: true,
|
|
1704
2141
|
epic_key: checkpoint.epic_key,
|
|
1705
2142
|
epic_branch: checkpoint.epic_branch,
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
current_base_branch: restored.value.current_base_branch,
|
|
2143
|
+
/** `null` when the epic declares no scope; `false` when retirement failed. */
|
|
2144
|
+
scope_retired: scopeRetired,
|
|
1709
2145
|
counters: { ...checkpoint.counters },
|
|
1710
2146
|
needs_human: checkpoint.needs_human,
|
|
1711
2147
|
tickets: checkpoint.tickets.map((ticket) => ({
|
|
@@ -1717,7 +2153,11 @@ export async function runConductEpicFinish(deps, options) {
|
|
|
1717
2153
|
};
|
|
1718
2154
|
const humanLines = [
|
|
1719
2155
|
`Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
|
|
1720
|
-
`index
|
|
2156
|
+
`index scope: ${scopeRetired === null
|
|
2157
|
+
? "none declared"
|
|
2158
|
+
: scopeRetired
|
|
2159
|
+
? "retired (retention clock started; nothing deleted)"
|
|
2160
|
+
: "NOT retired — see the error above"}`,
|
|
1721
2161
|
`iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
|
|
1722
2162
|
...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
|
|
1723
2163
|
`spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
|
|
@@ -1727,6 +2167,213 @@ export async function runConductEpicFinish(deps, options) {
|
|
|
1727
2167
|
return emitSuccess(deps, options.json, summary, humanLines);
|
|
1728
2168
|
}
|
|
1729
2169
|
// ---------------------------------------------------------------------------
|
|
2170
|
+
// Index-scope lifecycle verbs (BAPI-846)
|
|
2171
|
+
// ---------------------------------------------------------------------------
|
|
2172
|
+
//
|
|
2173
|
+
// `recover`, `retire`, and `reclaim` all reach the authenticated Bridge API and
|
|
2174
|
+
// nothing else. There is deliberately no local Pinecone client, no SQL, and no
|
|
2175
|
+
// deletion path in this process: the server owns every destructive decision, and
|
|
2176
|
+
// a CLI that could delete directly would be a second authority with none of the
|
|
2177
|
+
// server's fencing, locking, or blocker checks.
|
|
2178
|
+
//
|
|
2179
|
+
// Each one persists the server's returned fencing epoch into the protected
|
|
2180
|
+
// checkpoint — never into argv or stdout — so a later heartbeat or retirement
|
|
2181
|
+
// uses the generation the server actually minted.
|
|
2182
|
+
/**
|
|
2183
|
+
* Resolve which scope a lifecycle verb targets.
|
|
2184
|
+
*
|
|
2185
|
+
* `--scope` wins when supplied; otherwise the epic's own scope is read from the
|
|
2186
|
+
* checkpoint. The explicit flag exists for the case the discovery surface is FOR:
|
|
2187
|
+
* a crashed epic whose local checkpoint is gone or was never written, whose scope
|
|
2188
|
+
* an operator found on `status`.
|
|
2189
|
+
*/
|
|
2190
|
+
async function resolveLifecycleScope(deps, options, checkpointPath) {
|
|
2191
|
+
if (options.scope !== undefined) {
|
|
2192
|
+
// An explicitly named scope carries no local lease state, so any epoch must
|
|
2193
|
+
// come from the server. Reading one from an unrelated checkpoint would send a
|
|
2194
|
+
// generation that belongs to a different scope.
|
|
2195
|
+
return { ok: true, scopeId: options.scope, checkpoint: null, leaseEpoch: null };
|
|
2196
|
+
}
|
|
2197
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
2198
|
+
if (read.kind === "missing") {
|
|
2199
|
+
return {
|
|
2200
|
+
ok: false,
|
|
2201
|
+
reason: `No checkpoint exists at ${checkpointPath}. ` +
|
|
2202
|
+
"Pass --scope <id> to target a scope directly (see `conduct-epic status`).",
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
if (read.kind !== "ok")
|
|
2206
|
+
return { ok: false, reason: read.error };
|
|
2207
|
+
const scopeId = read.checkpoint.index_scope_id;
|
|
2208
|
+
if (typeof scopeId !== "string" || scopeId.length === 0) {
|
|
2209
|
+
return { ok: false, reason: `${options.epicKey} declares no index scope.` };
|
|
2210
|
+
}
|
|
2211
|
+
return {
|
|
2212
|
+
ok: true,
|
|
2213
|
+
scopeId,
|
|
2214
|
+
checkpoint: read.checkpoint,
|
|
2215
|
+
leaseEpoch: read.checkpoint.index_scope_lease_epoch,
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Persist the server's authoritative fencing epoch into the checkpoint.
|
|
2220
|
+
*
|
|
2221
|
+
* Best-effort by design: the lifecycle call already succeeded on the server, and
|
|
2222
|
+
* failing the command because a local cache write failed would report a
|
|
2223
|
+
* successful recovery as a failure. The next `status` re-reads the epoch anyway.
|
|
2224
|
+
*/
|
|
2225
|
+
async function persistScopeLeaseEpoch(deps, checkpointPath, checkpoint, leaseEpoch) {
|
|
2226
|
+
if (checkpoint === null || leaseEpoch === null)
|
|
2227
|
+
return;
|
|
2228
|
+
if (checkpoint.index_scope_lease_epoch === leaseEpoch)
|
|
2229
|
+
return;
|
|
2230
|
+
const next = {
|
|
2231
|
+
...checkpoint,
|
|
2232
|
+
index_scope_lease_epoch: leaseEpoch,
|
|
2233
|
+
updated_at: deps.now().toISOString(),
|
|
2234
|
+
};
|
|
2235
|
+
await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
2236
|
+
skipChmod: deps.platform === "win32",
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
/** `conduct-epic recover` — take a new ownership generation for a scope. */
|
|
2240
|
+
export async function runConductEpicRecover(deps, options) {
|
|
2241
|
+
const accessProbe = await resolveAccess(deps);
|
|
2242
|
+
if (!accessProbe.ok)
|
|
2243
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2244
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2245
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2246
|
+
if (!target.ok)
|
|
2247
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2248
|
+
const recovered = await recoverIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
|
|
2249
|
+
if (!recovered.ok) {
|
|
2250
|
+
return emitFailure(deps, options.json, [
|
|
2251
|
+
`The index scope could not be recovered: ${recovered.error}`,
|
|
2252
|
+
]);
|
|
2253
|
+
}
|
|
2254
|
+
await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, recovered.value.lease_epoch);
|
|
2255
|
+
return emitSuccess(deps, options.json, {
|
|
2256
|
+
ok: true,
|
|
2257
|
+
epic_key: options.epicKey,
|
|
2258
|
+
scope_id: recovered.value.scope_id,
|
|
2259
|
+
lifecycle_state: recovered.value.lifecycle_state,
|
|
2260
|
+
lease_epoch: recovered.value.lease_epoch,
|
|
2261
|
+
lease_expires_at: recovered.value.lease_expires_at,
|
|
2262
|
+
}, [
|
|
2263
|
+
`Recovered index scope ${recovered.value.scope_id}.`,
|
|
2264
|
+
` lifecycle: ${recovered.value.lifecycle_state ?? "unknown"}`,
|
|
2265
|
+
` lease epoch: ${recovered.value.lease_epoch ?? "unknown"} (previous owners are now fenced)`,
|
|
2266
|
+
` lease expires: ${recovered.value.lease_expires_at ?? "unknown"}`,
|
|
2267
|
+
]);
|
|
2268
|
+
}
|
|
2269
|
+
/** `conduct-epic retire` — start the retention clock; delete nothing. */
|
|
2270
|
+
export async function runConductEpicRetire(deps, options) {
|
|
2271
|
+
const accessProbe = await resolveAccess(deps);
|
|
2272
|
+
if (!accessProbe.ok)
|
|
2273
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2274
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2275
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2276
|
+
if (!target.ok)
|
|
2277
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2278
|
+
const outcome = await retireScopeWithEpoch(deps, options, accessProbe.access, target);
|
|
2279
|
+
if (!outcome.ok)
|
|
2280
|
+
return emitFailure(deps, options.json, [outcome.reason]);
|
|
2281
|
+
return emitSuccess(deps, options.json, {
|
|
2282
|
+
ok: true,
|
|
2283
|
+
epic_key: options.epicKey,
|
|
2284
|
+
scope_id: outcome.state.scope_id,
|
|
2285
|
+
lifecycle_state: outcome.state.lifecycle_state,
|
|
2286
|
+
lease_epoch: outcome.state.lease_epoch,
|
|
2287
|
+
already_retired: outcome.state.already_retired,
|
|
2288
|
+
}, [
|
|
2289
|
+
outcome.state.already_retired
|
|
2290
|
+
? `Index scope ${outcome.state.scope_id} was already retired; retention clock unchanged.`
|
|
2291
|
+
: `Retired index scope ${outcome.state.scope_id}. Nothing was deleted.`,
|
|
2292
|
+
" The scope stays readable for post-mortem for the whole retention window.",
|
|
2293
|
+
]);
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Retire a scope, resolving the fencing epoch the server currently holds.
|
|
2297
|
+
*
|
|
2298
|
+
* The epoch is the one thing retirement needs and the one thing a local
|
|
2299
|
+
* checkpoint can be wrong about — an operator `recover` (or another conductor)
|
|
2300
|
+
* may have superseded it. So a locally-cached epoch is used only as a first
|
|
2301
|
+
* attempt; on a fencing refusal the CURRENT epoch is read from the server's
|
|
2302
|
+
* listing and the retirement is retried ONCE. That is not a retry loop papering
|
|
2303
|
+
* over a race: retirement is idempotent and the second attempt uses an epoch the
|
|
2304
|
+
* server itself just reported.
|
|
2305
|
+
*/
|
|
2306
|
+
async function retireScopeWithEpoch(deps, options, access, target) {
|
|
2307
|
+
let epoch = target.leaseEpoch;
|
|
2308
|
+
if (epoch === null) {
|
|
2309
|
+
const current = await lookupScopeEpoch(deps, access, target.scopeId);
|
|
2310
|
+
if (current === null) {
|
|
2311
|
+
return {
|
|
2312
|
+
ok: false,
|
|
2313
|
+
reason: `The current fencing epoch for scope ${target.scopeId} could not be read.`,
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
epoch = current;
|
|
2317
|
+
}
|
|
2318
|
+
let retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
2319
|
+
if (!retired.ok) {
|
|
2320
|
+
const current = await lookupScopeEpoch(deps, access, target.scopeId);
|
|
2321
|
+
if (current !== null && current !== epoch) {
|
|
2322
|
+
epoch = current;
|
|
2323
|
+
retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
if (!retired.ok) {
|
|
2327
|
+
return { ok: false, reason: `The index scope could not be retired: ${retired.error}` };
|
|
2328
|
+
}
|
|
2329
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2330
|
+
await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, retired.value.lease_epoch);
|
|
2331
|
+
return { ok: true, state: retired.value };
|
|
2332
|
+
}
|
|
2333
|
+
/** Read one scope's CURRENT fencing epoch from the authoritative listing. */
|
|
2334
|
+
async function lookupScopeEpoch(deps, access, scopeId) {
|
|
2335
|
+
const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
|
|
2336
|
+
if (!listing.ok)
|
|
2337
|
+
return null;
|
|
2338
|
+
const entry = listing.value.scopes.find((scope) => scope.scope_id === scopeId);
|
|
2339
|
+
return entry?.lease_epoch ?? null;
|
|
2340
|
+
}
|
|
2341
|
+
/** `conduct-epic reclaim` — ask the server to schedule the scope's teardown. */
|
|
2342
|
+
export async function runConductEpicReclaim(deps, options) {
|
|
2343
|
+
const accessProbe = await resolveAccess(deps);
|
|
2344
|
+
if (!accessProbe.ok)
|
|
2345
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2346
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2347
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2348
|
+
if (!target.ok)
|
|
2349
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2350
|
+
const scheduled = await reclaimIndexScope(accessProbe.access, { scopeId: target.scopeId, overrideRetention: options.overrideRetention }, deps.fetchImpl);
|
|
2351
|
+
if (!scheduled.ok) {
|
|
2352
|
+
// A refusal names the BLOCKERS the server evaluated, so an operator learns
|
|
2353
|
+
// that a parse is running rather than that "reclaim failed".
|
|
2354
|
+
const blockers = scheduled.blockers ?? [];
|
|
2355
|
+
const reasons = [`The index scope could not be reclaimed: ${scheduled.error}`];
|
|
2356
|
+
if (blockers.length > 0)
|
|
2357
|
+
reasons.push(` blocked by: ${blockers.join(", ")}`);
|
|
2358
|
+
return emitFailure(deps, options.json, reasons, {
|
|
2359
|
+
epic_key: options.epicKey,
|
|
2360
|
+
scope_id: target.scopeId,
|
|
2361
|
+
blockers,
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
return emitSuccess(deps, options.json, {
|
|
2365
|
+
ok: true,
|
|
2366
|
+
epic_key: options.epicKey,
|
|
2367
|
+
scope_id: scheduled.value.scope_id ?? target.scopeId,
|
|
2368
|
+
scheduled: scheduled.value.scheduled,
|
|
2369
|
+
}, [
|
|
2370
|
+
`Scheduled reclamation of index scope ${scheduled.value.scope_id ?? target.scopeId}.`,
|
|
2371
|
+
" This is SCHEDULED, not done — the teardown waits out Pinecone's",
|
|
2372
|
+
" consistency window. Run `conduct-epic status --json` to see it reach",
|
|
2373
|
+
" `reclaimed`.",
|
|
2374
|
+
]);
|
|
2375
|
+
}
|
|
2376
|
+
// ---------------------------------------------------------------------------
|
|
1730
2377
|
// Entry point
|
|
1731
2378
|
// ---------------------------------------------------------------------------
|
|
1732
2379
|
/**
|
|
@@ -1761,5 +2408,11 @@ export async function runConductEpicCli(argv, overrides = {}) {
|
|
|
1761
2408
|
return runConductEpicSpawn(deps, options);
|
|
1762
2409
|
case "finish":
|
|
1763
2410
|
return runConductEpicFinish(deps, options);
|
|
2411
|
+
case "recover":
|
|
2412
|
+
return runConductEpicRecover(deps, options);
|
|
2413
|
+
case "retire":
|
|
2414
|
+
return runConductEpicRetire(deps, options);
|
|
2415
|
+
case "reclaim":
|
|
2416
|
+
return runConductEpicReclaim(deps, options);
|
|
1764
2417
|
}
|
|
1765
2418
|
}
|