@bridge_gpt/mcp-server 0.2.54 → 0.2.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -54
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +5 -4
- package/build/conductor/bridge-api-client.js +199 -6
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +1 -1
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +37 -6
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +77 -17
- package/build/drive-epic.js +541 -115
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +274 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +586 -138
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +7 -7
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +36 -5
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +4 -4
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +1 -1
- package/build/setup-epic.js +69 -31
- package/build/start-tickets-conductor.js +8 -7
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/package.json +5 -3
|
@@ -0,0 +1,3158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The packaged `implement-epic` CLI (BAPI-803).
|
|
3
|
+
*
|
|
4
|
+
* `/conduct-epic` (BAPI-804) is prose. (S6 seam: this slash-command mirror is
|
|
5
|
+
* renamed separately from the packaged `implement-epic` CLI below — S4/BAPI-1080
|
|
6
|
+
* renames only the packaged positional command.) Prose cannot be unit-tested, and the
|
|
7
|
+
* guarantees the loop depends on — that the epic branch was created from the
|
|
8
|
+
* right base, that a checkpoint write is atomic, that two sessions cannot edit
|
|
9
|
+
* one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
|
|
10
|
+
* before the first ticket is dispatched — are exactly the kind that fail
|
|
11
|
+
* silently when they live in an instruction file. So they live here, behind the
|
|
12
|
+
* verbs the loop calls and this file's tests pin:
|
|
13
|
+
*
|
|
14
|
+
* init | status | checkpoint set | finish | spawn
|
|
15
|
+
* recover | retire | reclaim | catch-up (index-scope lifecycle)
|
|
16
|
+
* scopes (repository-scoped inventory)
|
|
17
|
+
*
|
|
18
|
+
* `scopes` (BAPI-963) is the one verb that takes no `<EPIC>` key: it answers
|
|
19
|
+
* "what does this repository own?", which is not a question about any one epic.
|
|
20
|
+
*
|
|
21
|
+
* Design rules that hold across all of them:
|
|
22
|
+
*
|
|
23
|
+
* - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
|
|
24
|
+
* The one epic-run call it makes (`getEpicRunState`) is a read whose only
|
|
25
|
+
* purpose is to REFUSE when the server-side v2 reconciler is already
|
|
26
|
+
* conducting the same epic — two conductors on one epic is the failure mode.
|
|
27
|
+
* - **Every process/network/filesystem boundary is injected** through
|
|
28
|
+
* {@link ConductEpicDeps}, so the whole surface is testable without a real
|
|
29
|
+
* `gh`, terminal, home directory, or Bridge server.
|
|
30
|
+
* - **`init` accumulates.** Its preflight runs every independent check and
|
|
31
|
+
* prints every failure in one pass. Failing on the first one makes an operator
|
|
32
|
+
* discover a five-problem environment five invocations at a time.
|
|
33
|
+
* - **`status` fails open per probe, `status` never takes the lock.** A `gh`
|
|
34
|
+
* outage must not stop the loop from seeing its checkpoint; a read-only
|
|
35
|
+
* surface that took a lock would deadlock against the verb that needs it.
|
|
36
|
+
* - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
|
|
37
|
+
* never enter argv, stdout, stderr, a journal line, or an error string.
|
|
38
|
+
*/
|
|
39
|
+
import { promises as nodeFs } from "node:fs";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { validateBranchName } from "../base-ref.js";
|
|
43
|
+
import { parseDoneGateConfig } from "../conductor/done-gate.js";
|
|
44
|
+
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
|
|
45
|
+
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
46
|
+
import { runGhCommand } from "../conductor/pr-discovery.js";
|
|
47
|
+
import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
|
|
48
|
+
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
49
|
+
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
50
|
+
import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseDispatcherHealth, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
|
|
51
|
+
import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
|
|
52
|
+
import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
|
|
53
|
+
import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
|
|
54
|
+
import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
|
|
55
|
+
import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
|
|
56
|
+
import { fetchLatestVersion } from "../cli-release.js";
|
|
57
|
+
import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
|
|
58
|
+
// BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
|
|
59
|
+
// local-git helpers live in ONE shared module that `setup-epic` drives too. This
|
|
60
|
+
// file remains the pilot's owner of the preflight and of how a cut outcome is
|
|
61
|
+
// reported; the cut itself is performed by the shared module.
|
|
62
|
+
import { createExecFileRunCommand, describeScopeBootstrapWindow, firstOutputLine as firstLine, formatScopeBootstrapHeartbeat, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_UNREADABLE_STATE, } from "./cut-protocol.js";
|
|
63
|
+
// Re-exported so existing importers of the pilot's normalizer keep compiling.
|
|
64
|
+
export { normalizeCommitSha };
|
|
65
|
+
/** Epic and ticket keys accepted by every verb. */
|
|
66
|
+
export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
67
|
+
/** The verb families. `checkpoint set` is two tokens, one verb. */
|
|
68
|
+
export const CONDUCT_EPIC_VERBS = [
|
|
69
|
+
"init",
|
|
70
|
+
"status",
|
|
71
|
+
"checkpoint set",
|
|
72
|
+
"finish",
|
|
73
|
+
"spawn",
|
|
74
|
+
"recover",
|
|
75
|
+
"retire",
|
|
76
|
+
"reclaim",
|
|
77
|
+
"catch-up",
|
|
78
|
+
"scopes",
|
|
79
|
+
];
|
|
80
|
+
/** Per-ticket fields `checkpoint set` may assign. */
|
|
81
|
+
const TICKET_FIELDS = [
|
|
82
|
+
"status",
|
|
83
|
+
"branch",
|
|
84
|
+
"pr_number",
|
|
85
|
+
"spawned_at",
|
|
86
|
+
"parse_requested_at",
|
|
87
|
+
"parse_requested_for_sha",
|
|
88
|
+
"review_verdictless_observations",
|
|
89
|
+
"review_verdictless_for_sha",
|
|
90
|
+
"respawns",
|
|
91
|
+
"conflict_attempts",
|
|
92
|
+
"counters.sessions_spawned",
|
|
93
|
+
"counters.plan_generations_observed",
|
|
94
|
+
"counters.merge_attempts",
|
|
95
|
+
];
|
|
96
|
+
/** Top-level fields `checkpoint set` may assign. */
|
|
97
|
+
const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
|
|
98
|
+
/** Build the production dependency set. */
|
|
99
|
+
export function createDefaultConductEpicDeps() {
|
|
100
|
+
const runCommand = createExecFileRunCommand();
|
|
101
|
+
const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
|
|
102
|
+
const startTicketsDeps = createDefaultStartTicketsDeps();
|
|
103
|
+
// Strict enabled-value check (matches `MCP_INTEGRATION === "1"` in
|
|
104
|
+
// `integration/harness.ts`): only the exact value "1" activates the
|
|
105
|
+
// fixture, so no broadly truthy ambient environment value can enable it
|
|
106
|
+
// by accident.
|
|
107
|
+
const publishedIdentityFixture = process.env[CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV] === "1"
|
|
108
|
+
? CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE
|
|
109
|
+
: undefined;
|
|
110
|
+
return {
|
|
111
|
+
runCommand,
|
|
112
|
+
runGh: (args, options) => runGhCommand(args, options ?? {}),
|
|
113
|
+
epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
|
|
114
|
+
spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
|
|
115
|
+
fetchImpl: globalThis.fetch,
|
|
116
|
+
fs: {
|
|
117
|
+
mkdir: (dirPath, options) => nodeFs.mkdir(dirPath, options),
|
|
118
|
+
readFile: (filePath) => nodeFs.readFile(filePath, "utf-8"),
|
|
119
|
+
writeFile: (filePath, data, options) => nodeFs.writeFile(filePath, data, { encoding: "utf-8", mode: options?.mode }),
|
|
120
|
+
// Real exclusive create: `wx` fails EEXIST in the kernel, so two racing
|
|
121
|
+
// callers cannot both believe they created the file. Never reconstruct
|
|
122
|
+
// this from `stat` + `writeFile`.
|
|
123
|
+
writeFileExclusive: async (filePath, data, options) => {
|
|
124
|
+
const handle = await nodeFs.open(filePath, "wx", options?.mode);
|
|
125
|
+
try {
|
|
126
|
+
await handle.writeFile(data, "utf-8");
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await handle.close();
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
rename: (fromPath, toPath) => nodeFs.rename(fromPath, toPath),
|
|
133
|
+
chmod: (filePath, mode) => nodeFs.chmod(filePath, mode),
|
|
134
|
+
unlink: (filePath) => nodeFs.unlink(filePath),
|
|
135
|
+
stat: (filePath) => nodeFs.stat(filePath),
|
|
136
|
+
},
|
|
137
|
+
now: () => new Date(),
|
|
138
|
+
env: process.env,
|
|
139
|
+
homedir: os.homedir,
|
|
140
|
+
hostname: os.hostname,
|
|
141
|
+
platform: process.platform,
|
|
142
|
+
cwd: process.cwd(),
|
|
143
|
+
pid: process.pid,
|
|
144
|
+
isProcessAlive: isConductEpicLockOwnerAlive,
|
|
145
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
146
|
+
log: (m) => console.log(m),
|
|
147
|
+
errorLog: (m) => console.error(m),
|
|
148
|
+
resolveAccess: resolveConductorBridgeApiAccess,
|
|
149
|
+
resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
|
|
150
|
+
publishedIdentityFixture,
|
|
151
|
+
resolveRepoName: resolveRequiredStartTicketsRepoName,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Usage + argument parsing
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
/** User-facing usage text for the packaged `implement-epic` subcommand. */
|
|
158
|
+
export function getConductEpicUsage() {
|
|
159
|
+
return [
|
|
160
|
+
"Usage:",
|
|
161
|
+
` npx -y ${MCP_PACKAGE_NAME} implement-epic <verb> [flags]`,
|
|
162
|
+
"",
|
|
163
|
+
"implement-epic drives EPIC-LEVEL orchestration (a local, index-scope-lifecycle",
|
|
164
|
+
"loop over an epic's tickets). It is a DIFFERENT flow from the ticket-level",
|
|
165
|
+
"`implement-ticket` command, which implements one ticket; implement-epic never",
|
|
166
|
+
"replaces it.",
|
|
167
|
+
"",
|
|
168
|
+
"Verbs:",
|
|
169
|
+
" init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
|
|
170
|
+
" Run the full preflight, then create epic/<EPIC> on origin at the commit the",
|
|
171
|
+
" CANONICAL INDEX covers — not the base tip — seed and verify the epic's index",
|
|
172
|
+
" scope at that commit, repoint the indexed branch, write the checkpoint, and",
|
|
173
|
+
" take the lock. --base-branch selects the base whose history is fetched and",
|
|
174
|
+
" recorded; the cut commit is the canonical indexed SHA and is reported",
|
|
175
|
+
" separately. init fails closed when the repository has no successful parse.",
|
|
176
|
+
" Every preflight failure is printed in one pass; nothing is written unless",
|
|
177
|
+
" all of them pass. --dry-run prints the validated plan and writes nothing.",
|
|
178
|
+
"",
|
|
179
|
+
" status <EPIC> [--checkpoint-path <p>] --json",
|
|
180
|
+
" Print one JSON object describing the in-flight ticket, its worktree, PR,",
|
|
181
|
+
" CI, review, parse, deadline, and lock state. --json is required. A missing",
|
|
182
|
+
" checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
|
|
183
|
+
" sub-object null and is listed in probe_errors; it never fails the command.",
|
|
184
|
+
" status warns about STRANDED scopes only; run `implement-epic scopes` below",
|
|
185
|
+
" for the full repository-wide inventory.",
|
|
186
|
+
"",
|
|
187
|
+
" scopes [--json]",
|
|
188
|
+
" Repository-scoped, and the ONE verb that takes no <EPIC> key. Lists EVERY",
|
|
189
|
+
" index scope this repository owns — live, expired, reclaiming, and",
|
|
190
|
+
" reclaimed alike — so a crashed epic is visible without SQL. Read-only:",
|
|
191
|
+
" it writes nothing and takes no lease.",
|
|
192
|
+
"",
|
|
193
|
+
" checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
|
|
194
|
+
" [--checkpoint-path <p>]",
|
|
195
|
+
" Apply ABSOLUTE field values (the caller computes n+1 from status).",
|
|
196
|
+
` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
|
|
197
|
+
` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
|
|
198
|
+
" Repeat --field to write several in ONE atomic mutation.",
|
|
199
|
+
" parse_requested_at / parse_requested_for_sha are ACCEPTED for older",
|
|
200
|
+
" checkpoints but no longer written: freshness is read from the scope.",
|
|
201
|
+
"",
|
|
202
|
+
" finish <EPIC> [--checkpoint-path <p>] [--json]",
|
|
203
|
+
" Restore the server's indexed base branch (idempotent), release the owned",
|
|
204
|
+
" lock, and print the final summary. A second finish succeeds.",
|
|
205
|
+
"",
|
|
206
|
+
` spawn <EPIC> --ticket <KEY> --prompt-file <path> [--agent ${CONDUCT_EPIC_AGENTS.join("|")}]`,
|
|
207
|
+
" [--checkpoint-path <p>] [--json]",
|
|
208
|
+
" Open exactly ONE agent tab in the ticket's worktree running the prompt",
|
|
209
|
+
" file's contents, then increment counters.sessions_spawned and append a",
|
|
210
|
+
" journal line. Respawn and conflict budgets are the CALLER's job.",
|
|
211
|
+
"",
|
|
212
|
+
" recover <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
|
|
213
|
+
" Take a NEW ownership generation for a crashed epic's index scope and",
|
|
214
|
+
" record the returned fencing epoch locally. Use this instead of SQL when",
|
|
215
|
+
" `status` shows a scope whose lease expired. Defaults to the epic's own",
|
|
216
|
+
" scope; --scope targets another one (e.g. when the checkpoint is gone).",
|
|
217
|
+
"",
|
|
218
|
+
" retire <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
|
|
219
|
+
" Start the scope's retention clock. Deletes NOTHING — the scope stays",
|
|
220
|
+
" readable for post-mortem for the whole retention window. Idempotent.",
|
|
221
|
+
" `finish` does this for you; this verb is for retiring without finishing.",
|
|
222
|
+
"",
|
|
223
|
+
" catch-up <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
|
|
224
|
+
" GUARDED CATCH-UP: replay merges the server never observed, so a scope",
|
|
225
|
+
" stranded behind its epic branch can be repaired without waiting for the",
|
|
226
|
+
" next ticket to merge. Every replayed commit goes through the SAME AC-10",
|
|
227
|
+
" gate a merge webhook would have used — this replays verified merge",
|
|
228
|
+
" evidence, it does NOT force the branch into the index, and there is no",
|
|
229
|
+
" --force, --override, or skip-guard flag on purpose. A refusal leaves both",
|
|
230
|
+
" commit watermarks untouched and schedules no parse. Running it twice",
|
|
231
|
+
" leaves the same state as running it once.",
|
|
232
|
+
"",
|
|
233
|
+
" reclaim <EPIC> [--scope <id>] [--override-retention] [--checkpoint-path <p>] [--json]",
|
|
234
|
+
" Ask the server to schedule the scope's teardown: three Pinecone",
|
|
235
|
+
" namespaces, six parse-table slices, three config rows, and a retained",
|
|
236
|
+
" tombstone. Returns as soon as it is SCHEDULED; watch `status` for the",
|
|
237
|
+
" result. --override-retention waives only the still-valid-lease and",
|
|
238
|
+
" unelapsed-retention waits — an active parse, a held parse lock, a live",
|
|
239
|
+
" automation run, or a live epic run still refuse. There is no raw",
|
|
240
|
+
" deletion mode.",
|
|
241
|
+
"",
|
|
242
|
+
"Common:",
|
|
243
|
+
" -h, --help Show this help",
|
|
244
|
+
"",
|
|
245
|
+
"State lives outside the repository, at ~/.config/bridge/conduct/<repo>/ ",
|
|
246
|
+
"(honoring XDG_CONFIG_HOME): <EPIC>.json, <EPIC>.json.prev, <EPIC>.lock, and",
|
|
247
|
+
"<EPIC>/prompts/. The directory is 0700 and files are 0600.",
|
|
248
|
+
"",
|
|
249
|
+
"Exit codes: 0 on success (including checkpoint_exists:false and an idempotent",
|
|
250
|
+
"finish); non-zero on any failure, with a one-line reason on stderr. With --json,",
|
|
251
|
+
"stdout is exactly one JSON object carrying ok.",
|
|
252
|
+
].join("\n");
|
|
253
|
+
}
|
|
254
|
+
function parseError(message) {
|
|
255
|
+
return { status: "error", message };
|
|
256
|
+
}
|
|
257
|
+
/** Which flags each verb accepts. An unknown flag for the verb is rejected. */
|
|
258
|
+
const VERB_FLAGS = {
|
|
259
|
+
init: ["--tickets", "--base-branch", "--checkpoint-path", "--dry-run", "--json"],
|
|
260
|
+
status: ["--checkpoint-path", "--json"],
|
|
261
|
+
"checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
|
|
262
|
+
finish: ["--checkpoint-path", "--json"],
|
|
263
|
+
spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
|
|
264
|
+
recover: ["--scope", "--checkpoint-path", "--json"],
|
|
265
|
+
retire: ["--scope", "--checkpoint-path", "--json"],
|
|
266
|
+
reclaim: ["--scope", "--override-retention", "--checkpoint-path", "--json"],
|
|
267
|
+
// BAPI-932. NOTE the flags that are deliberately absent: there is no --force,
|
|
268
|
+
// no --override, and no --skip-guards, and adding one would defeat the point of
|
|
269
|
+
// the verb. Catch-up replays commits the SERVER reads through the unchanged
|
|
270
|
+
// AC-10 gate; it cannot make that gate say yes.
|
|
271
|
+
"catch-up": ["--scope", "--checkpoint-path", "--json"],
|
|
272
|
+
// BAPI-963. `--json` ONLY: this verb reads a repository-wide listing, so there
|
|
273
|
+
// is no checkpoint to point at and no scope to single out. The absence of
|
|
274
|
+
// --force/--override/--skip-guard here is the same deliberate absence as above.
|
|
275
|
+
scopes: ["--json"],
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* Parse and fully validate argv BEFORE any I/O.
|
|
279
|
+
*
|
|
280
|
+
* Rejects unknown verbs, unknown flags, duplicate singleton options, missing
|
|
281
|
+
* values, malformed keys, invalid branch names, and stray positionals. Every one
|
|
282
|
+
* of those is a mistake a caller can make in a generated shell command, and
|
|
283
|
+
* catching them here means a bad invocation never spawns a process, resolves a
|
|
284
|
+
* credential, or touches the checkpoint.
|
|
285
|
+
*/
|
|
286
|
+
export function parseConductEpicArgs(argv) {
|
|
287
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
288
|
+
return { status: "help", usage: getConductEpicUsage() };
|
|
289
|
+
}
|
|
290
|
+
if (argv.length === 0)
|
|
291
|
+
return parseError("A verb is required.");
|
|
292
|
+
let verb;
|
|
293
|
+
let rest;
|
|
294
|
+
if (argv[0] === "checkpoint") {
|
|
295
|
+
if (argv[1] !== "set") {
|
|
296
|
+
return parseError("Unknown verb 'checkpoint " + (argv[1] ?? "") + "'. Expected 'checkpoint set'.");
|
|
297
|
+
}
|
|
298
|
+
verb = "checkpoint-set";
|
|
299
|
+
rest = argv.slice(2);
|
|
300
|
+
}
|
|
301
|
+
else if (argv[0] === "init" ||
|
|
302
|
+
argv[0] === "status" ||
|
|
303
|
+
argv[0] === "finish" ||
|
|
304
|
+
argv[0] === "spawn" ||
|
|
305
|
+
argv[0] === "recover" ||
|
|
306
|
+
argv[0] === "retire" ||
|
|
307
|
+
argv[0] === "reclaim" ||
|
|
308
|
+
argv[0] === "catch-up" ||
|
|
309
|
+
argv[0] === "scopes") {
|
|
310
|
+
verb = argv[0];
|
|
311
|
+
rest = argv.slice(1);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
return parseError(`Unknown verb '${argv[0]}'. Expected one of: ${CONDUCT_EPIC_VERBS.join(", ")}.`);
|
|
315
|
+
}
|
|
316
|
+
const allowed = VERB_FLAGS[verb];
|
|
317
|
+
const options = {
|
|
318
|
+
verb,
|
|
319
|
+
epicKey: "",
|
|
320
|
+
tickets: [],
|
|
321
|
+
fields: [],
|
|
322
|
+
dryRun: false,
|
|
323
|
+
json: false,
|
|
324
|
+
overrideRetention: false,
|
|
325
|
+
};
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
let epicKey;
|
|
328
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
329
|
+
const arg = rest[i];
|
|
330
|
+
if (!arg.startsWith("-")) {
|
|
331
|
+
if (epicKey !== undefined) {
|
|
332
|
+
return parseError(`Unexpected argument '${arg}'.`);
|
|
333
|
+
}
|
|
334
|
+
epicKey = arg;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!allowed.includes(arg)) {
|
|
338
|
+
return parseError(`Unknown flag '${arg}' for '${verbLabel(verb)}'.`);
|
|
339
|
+
}
|
|
340
|
+
// `--field` is the one repeatable flag; everything else is a singleton.
|
|
341
|
+
if (arg !== "--field") {
|
|
342
|
+
if (seen.has(arg))
|
|
343
|
+
return parseError(`Duplicate flag '${arg}'.`);
|
|
344
|
+
seen.add(arg);
|
|
345
|
+
}
|
|
346
|
+
switch (arg) {
|
|
347
|
+
case "--dry-run":
|
|
348
|
+
options.dryRun = true;
|
|
349
|
+
break;
|
|
350
|
+
case "--json":
|
|
351
|
+
options.json = true;
|
|
352
|
+
break;
|
|
353
|
+
case "--override-retention":
|
|
354
|
+
options.overrideRetention = true;
|
|
355
|
+
break;
|
|
356
|
+
case "--field": {
|
|
357
|
+
const name = rest[i + 1];
|
|
358
|
+
const value = rest[i + 2];
|
|
359
|
+
if (name === undefined || value === undefined) {
|
|
360
|
+
return parseError("--field requires a name and a value.");
|
|
361
|
+
}
|
|
362
|
+
options.fields.push({ name, value });
|
|
363
|
+
i += 2;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
default: {
|
|
367
|
+
const value = rest[i + 1];
|
|
368
|
+
if (value === undefined)
|
|
369
|
+
return parseError(`${arg} requires a value.`);
|
|
370
|
+
i += 1;
|
|
371
|
+
const assigned = assignFlagValue(options, arg, value);
|
|
372
|
+
if (assigned !== null)
|
|
373
|
+
return parseError(assigned);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// BAPI-963: `scopes` is repository-scoped and takes NO positional. Rejecting a
|
|
378
|
+
// supplied key rather than ignoring it keeps the promise `--help` makes exact —
|
|
379
|
+
// an operator who types `implement-epic scopes BAPI-1` learns the verb's shape
|
|
380
|
+
// instead of silently getting a listing that ignored their argument.
|
|
381
|
+
if (verb === "scopes") {
|
|
382
|
+
if (epicKey !== undefined) {
|
|
383
|
+
return parseError(`'scopes' takes no <EPIC> key; it lists every scope this repository owns.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
if (epicKey === undefined)
|
|
388
|
+
return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
|
|
389
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
|
|
390
|
+
return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
|
|
391
|
+
}
|
|
392
|
+
options.epicKey = epicKey;
|
|
393
|
+
}
|
|
394
|
+
const missing = requiredFlagError(options);
|
|
395
|
+
if (missing !== null)
|
|
396
|
+
return parseError(missing);
|
|
397
|
+
return { status: "ok", options };
|
|
398
|
+
}
|
|
399
|
+
/** Human label for a verb in an error message. */
|
|
400
|
+
function verbLabel(verb) {
|
|
401
|
+
return verb === "checkpoint-set" ? "checkpoint set" : verb;
|
|
402
|
+
}
|
|
403
|
+
/** Apply one value-taking flag, returning an error message or `null`. */
|
|
404
|
+
function assignFlagValue(options, flag, value) {
|
|
405
|
+
switch (flag) {
|
|
406
|
+
case "--tickets": {
|
|
407
|
+
const keys = value
|
|
408
|
+
.split(",")
|
|
409
|
+
.map((k) => k.trim())
|
|
410
|
+
.filter((k) => k.length > 0);
|
|
411
|
+
if (keys.length === 0)
|
|
412
|
+
return "--tickets requires at least one ticket key.";
|
|
413
|
+
for (const key of keys) {
|
|
414
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(key)) {
|
|
415
|
+
return `Invalid ticket key '${key}' in --tickets. Expected the form PROJ-123.`;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (new Set(keys).size !== keys.length)
|
|
419
|
+
return "--tickets must not repeat a ticket key.";
|
|
420
|
+
options.tickets = keys;
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
case "--ticket": {
|
|
424
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(value)) {
|
|
425
|
+
return `Invalid ticket key '${value}'. Expected the form PROJ-123.`;
|
|
426
|
+
}
|
|
427
|
+
options.ticket = value;
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
case "--base-branch": {
|
|
431
|
+
const reason = validateBranchName(value);
|
|
432
|
+
if (reason)
|
|
433
|
+
return `Invalid --base-branch value: ${reason}`;
|
|
434
|
+
options.baseBranch = value;
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
case "--checkpoint-path":
|
|
438
|
+
if (value.trim().length === 0)
|
|
439
|
+
return "--checkpoint-path requires a path.";
|
|
440
|
+
options.checkpointPath = value;
|
|
441
|
+
return null;
|
|
442
|
+
case "--prompt-file":
|
|
443
|
+
if (value.trim().length === 0)
|
|
444
|
+
return "--prompt-file requires a path.";
|
|
445
|
+
options.promptFile = value;
|
|
446
|
+
return null;
|
|
447
|
+
case "--agent":
|
|
448
|
+
if (!CONDUCT_EPIC_AGENTS.includes(value)) {
|
|
449
|
+
return `Unsupported agent '${value}'. Expected one of: ${CONDUCT_EPIC_AGENTS.join(", ")}`;
|
|
450
|
+
}
|
|
451
|
+
options.agent = value;
|
|
452
|
+
return null;
|
|
453
|
+
case "--journal":
|
|
454
|
+
options.journal = value;
|
|
455
|
+
return null;
|
|
456
|
+
case "--scope": {
|
|
457
|
+
// Shape-validated here, before any I/O: a server-minted scope id is a uuid4
|
|
458
|
+
// hex. Refusing a malformed value at the boundary means a typo never becomes
|
|
459
|
+
// an authenticated request naming something arbitrary.
|
|
460
|
+
if (!/^[0-9a-f]{32}$/.test(value)) {
|
|
461
|
+
return `Invalid --scope value '${value}'. Expected a 32-character index-scope id.`;
|
|
462
|
+
}
|
|
463
|
+
options.scope = value;
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
default:
|
|
467
|
+
return `Unknown flag '${flag}'.`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Verb-specific required-flag checks. */
|
|
471
|
+
function requiredFlagError(options) {
|
|
472
|
+
if (options.verb === "init" && options.tickets.length === 0) {
|
|
473
|
+
return "init requires --tickets K1,K2,...";
|
|
474
|
+
}
|
|
475
|
+
if (options.verb === "status" && !options.json) {
|
|
476
|
+
return "status requires --json.";
|
|
477
|
+
}
|
|
478
|
+
if (options.verb === "checkpoint-set") {
|
|
479
|
+
if (options.ticket === undefined)
|
|
480
|
+
return "checkpoint set requires --ticket <KEY>.";
|
|
481
|
+
if (options.fields.length === 0 && options.journal === undefined) {
|
|
482
|
+
return "checkpoint set requires at least one --field or --journal.";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (options.verb === "spawn") {
|
|
486
|
+
if (options.ticket === undefined)
|
|
487
|
+
return "spawn requires --ticket <KEY>.";
|
|
488
|
+
if (options.promptFile === undefined)
|
|
489
|
+
return "spawn requires --prompt-file <path>.";
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Output discipline
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
/**
|
|
497
|
+
* Emit one success result.
|
|
498
|
+
*
|
|
499
|
+
* With `--json`, stdout carries EXACTLY one JSON object and every human line is
|
|
500
|
+
* diverted to stderr — including the `announced:` line, so a caller parsing
|
|
501
|
+
* stdout never has to strip narration.
|
|
502
|
+
*/
|
|
503
|
+
function emitSuccess(deps, json, payload, humanLines = []) {
|
|
504
|
+
if (json) {
|
|
505
|
+
for (const line of humanLines)
|
|
506
|
+
deps.errorLog(line);
|
|
507
|
+
deps.log(JSON.stringify(payload, null, 2));
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
for (const line of humanLines)
|
|
511
|
+
deps.log(line);
|
|
512
|
+
}
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Emit one failure. Reasons go to stderr as concise lines; with `--json`, stdout
|
|
517
|
+
* still carries exactly one object, so a caller can parse a failure the same way
|
|
518
|
+
* it parses a success.
|
|
519
|
+
*/
|
|
520
|
+
function emitFailure(deps, json, reasons, payload = {}) {
|
|
521
|
+
for (const reason of reasons)
|
|
522
|
+
deps.errorLog(reason);
|
|
523
|
+
if (json) {
|
|
524
|
+
deps.log(JSON.stringify({ ok: false, ...payload, failures: reasons }, null, 2));
|
|
525
|
+
}
|
|
526
|
+
return 1;
|
|
527
|
+
}
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Shared helpers
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
/** The epic's own branch name. Fixed by contract; never operator-supplied. */
|
|
532
|
+
function epicBranchFor(epicKey) {
|
|
533
|
+
return `epic/${epicKey}`;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Ensure the draft epic-integration PR (BAPI-951), non-fatally. Never throws;
|
|
537
|
+
* returns the formatted, sanctioned-fields-only outcome for the caller's own
|
|
538
|
+
* advisory/announcement line.
|
|
539
|
+
*/
|
|
540
|
+
async function ensureEpicIntegrationPrAdvisory(deps, input) {
|
|
541
|
+
const gh = deps.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
|
|
542
|
+
try {
|
|
543
|
+
const outcome = await ensureEpicIntegrationPullRequest({
|
|
544
|
+
epicKey: input.epicKey,
|
|
545
|
+
epicBranch: input.epicBranch,
|
|
546
|
+
baseBranch: input.baseBranch,
|
|
547
|
+
command: input.command,
|
|
548
|
+
gh,
|
|
549
|
+
cwd: deps.cwd,
|
|
550
|
+
requestReady: input.requestReady,
|
|
551
|
+
});
|
|
552
|
+
return formatEpicIntegrationPullRequestOutcome(outcome);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return formatEpicIntegrationPullRequestOutcome({ kind: "unavailable", reason: "probe_inconclusive" });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Resolve the checkpoint path from `--checkpoint-path` or the default. */
|
|
559
|
+
function resolveCheckpointPath(deps, repoName, epicKey, override) {
|
|
560
|
+
if (override !== undefined)
|
|
561
|
+
return path.resolve(override);
|
|
562
|
+
return resolveConductEpicCheckpointPath(repoName, epicKey, {
|
|
563
|
+
env: deps.env,
|
|
564
|
+
homedir: deps.homedir,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The lock seams built from the injected deps.
|
|
569
|
+
*
|
|
570
|
+
* Exported so a test can drive concurrent acquisitions through the exact bridge
|
|
571
|
+
* every mutating verb uses — the production default in `lock.ts` is never
|
|
572
|
+
* reached from here, so this bridge is what the mutual-exclusion guarantee
|
|
573
|
+
* actually rests on.
|
|
574
|
+
*/
|
|
575
|
+
export function buildConductEpicLockSeams(deps) {
|
|
576
|
+
return {
|
|
577
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
578
|
+
removeFile: (filePath) => deps.fs.unlink(filePath),
|
|
579
|
+
mkdir: (dirPath, options) => deps.fs.mkdir(dirPath, options),
|
|
580
|
+
isProcessAlive: deps.isProcessAlive,
|
|
581
|
+
// The atomic exclusive-create primitive itself, not an exists-then-write
|
|
582
|
+
// pair: contention must surface as an EEXIST rejection from a single
|
|
583
|
+
// syscall, which is the whole mutual-exclusion signal.
|
|
584
|
+
writeFileExclusive: (filePath, data) => deps.fs.writeFileExclusive(filePath, data, { mode: 0o600 }),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** This invocation's lock identity. */
|
|
588
|
+
function lockRequest(deps) {
|
|
589
|
+
return {
|
|
590
|
+
ownerPid: deps.pid,
|
|
591
|
+
host: deps.hostname(),
|
|
592
|
+
acquiredAt: deps.now().toISOString(),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Resolve the repository name for PATH derivation, independent of credentials.
|
|
597
|
+
*
|
|
598
|
+
* Falls back to `"unknown"` only when the identity genuinely cannot be resolved
|
|
599
|
+
* from `BAPI_REPO_NAME` or `.bridge/config` — at which point no checkpoint could
|
|
600
|
+
* have been written under a real name either, so the two agree.
|
|
601
|
+
*/
|
|
602
|
+
async function resolveRepoNameForPath(deps) {
|
|
603
|
+
try {
|
|
604
|
+
const result = await deps.resolveRepoName({
|
|
605
|
+
env: deps.env,
|
|
606
|
+
cwd: deps.cwd,
|
|
607
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
608
|
+
});
|
|
609
|
+
if (result.ok)
|
|
610
|
+
return result.repoName;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* sanitized: an identity failure degrades to the placeholder below */
|
|
614
|
+
}
|
|
615
|
+
return "unknown";
|
|
616
|
+
}
|
|
617
|
+
/** Resolve Bridge access, or a sanitized reason. Never returns the key. */
|
|
618
|
+
async function resolveAccess(deps) {
|
|
619
|
+
const result = await deps.resolveAccess({
|
|
620
|
+
env: deps.env,
|
|
621
|
+
cwd: deps.cwd,
|
|
622
|
+
homedir: deps.homedir,
|
|
623
|
+
platform: deps.platform,
|
|
624
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
625
|
+
stat: (filePath) => deps.fs.stat(filePath),
|
|
626
|
+
});
|
|
627
|
+
if (result.ok)
|
|
628
|
+
return { ok: true, access: result.access };
|
|
629
|
+
return { ok: false, error: `Bridge credentials could not be resolved: ${result.error}` };
|
|
630
|
+
}
|
|
631
|
+
/** Run `git` with list args in the repository working directory. */
|
|
632
|
+
function git(deps, args) {
|
|
633
|
+
return runGit(cutProtocolDeps(deps), args);
|
|
634
|
+
}
|
|
635
|
+
/** The strict subset of the pilot's deps the shared cut protocol consumes. */
|
|
636
|
+
function cutProtocolDeps(deps) {
|
|
637
|
+
return {
|
|
638
|
+
runCommand: deps.runCommand,
|
|
639
|
+
cwd: deps.cwd,
|
|
640
|
+
fetchImpl: deps.fetchImpl,
|
|
641
|
+
errorLog: deps.errorLog,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function isRecord(value) {
|
|
645
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
646
|
+
}
|
|
647
|
+
/** Seconds between two ISO timestamps and `now`, clamped at zero. */
|
|
648
|
+
function elapsedSeconds(from, now) {
|
|
649
|
+
if (from === null)
|
|
650
|
+
return null;
|
|
651
|
+
const start = Date.parse(from);
|
|
652
|
+
if (Number.isNaN(start))
|
|
653
|
+
return null;
|
|
654
|
+
return Math.max(0, Math.floor((now.getTime() - start) / 1000));
|
|
655
|
+
}
|
|
656
|
+
/** The in-flight ticket: the FIRST entry that is not `done`. */
|
|
657
|
+
function inFlightTicket(checkpoint) {
|
|
658
|
+
return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
|
|
659
|
+
}
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
// The published-build identity (BAPI-873)
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
/**
|
|
664
|
+
* Hard bound on launching the published package to read its identity. `npx` may
|
|
665
|
+
* have to download a tarball on a cold cache, so this is generous relative to
|
|
666
|
+
* the registry lookup — but it is a bound, because a wedged launch must never
|
|
667
|
+
* stall `init` indefinitely.
|
|
668
|
+
*/
|
|
669
|
+
export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
|
|
670
|
+
/** The identity shape `--version` emits: 12 lowercase hex, optionally `-dirty`. */
|
|
671
|
+
const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
|
|
672
|
+
/** The sentinel a build with no git metadata reports. */
|
|
673
|
+
const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
|
|
674
|
+
/**
|
|
675
|
+
* Opt-out for the published-identity read, for spawned integration children
|
|
676
|
+
* ONLY (BAPI-944).
|
|
677
|
+
*
|
|
678
|
+
* The required `mcp-integration` CI lane spawns real `implement-epic init`
|
|
679
|
+
* children, and `readPublishedBuildIdentity` otherwise resolves the npm
|
|
680
|
+
* `latest` dist-tag and launches it through `npx` — a live registry
|
|
681
|
+
* dependency inside a lane that is supposed to be hermetic. Naming follows
|
|
682
|
+
* `INSTALL_REEXEC_SENTINEL` (`install-reexec.ts`); the strict-enabled-value
|
|
683
|
+
* convention follows `MCP_INTEGRATION` (`integration/harness.ts`). Kept
|
|
684
|
+
* distinct from `MCP_INTEGRATION` deliberately: that flag gates which test
|
|
685
|
+
* files run at all, this one gates a single dependency inside them, and
|
|
686
|
+
* conflating the two would make it impossible to run the integration suite
|
|
687
|
+
* against a real registry when that is exactly what is under test.
|
|
688
|
+
*/
|
|
689
|
+
export const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV = "BAPI_CONDUCTOR_PUBLISHED_IDENTITY_FIXTURE";
|
|
690
|
+
/**
|
|
691
|
+
* The static clean identity returned when the fixture seam is enabled.
|
|
692
|
+
*
|
|
693
|
+
* Non-secret and independent of the current checkout by construction — its
|
|
694
|
+
* commit is not expected to be present in any local history, so it exercises
|
|
695
|
+
* the SAME "commit unavailable locally" advisory path a real cold checkout
|
|
696
|
+
* would hit, rather than a synthesized pass.
|
|
697
|
+
*/
|
|
698
|
+
const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE = {
|
|
699
|
+
kind: "known",
|
|
700
|
+
version: "0.0.0-fixture",
|
|
701
|
+
commit: "deadbeefcafe",
|
|
702
|
+
dirty: false,
|
|
703
|
+
};
|
|
704
|
+
/** Human wording for each unavailable category, for the fail-open advisory. */
|
|
705
|
+
export function describePublishedIdentityReason(reason) {
|
|
706
|
+
switch (reason) {
|
|
707
|
+
case "registry_unreadable":
|
|
708
|
+
return "the npm registry could not be read";
|
|
709
|
+
case "launch_failed":
|
|
710
|
+
return "the published package could not be launched";
|
|
711
|
+
case "unreadable_output":
|
|
712
|
+
return "the published package reported no readable build identity";
|
|
713
|
+
case "identity_unknown":
|
|
714
|
+
return "the published build reports an unknown build commit";
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Read the commit identity embedded in the LATEST PUBLISHED package.
|
|
719
|
+
*
|
|
720
|
+
* Two bounded steps: resolve the exact latest version through the shared
|
|
721
|
+
* registry lookup, then run THAT EXACT VERSION with `--version`. The exactness
|
|
722
|
+
* matters — invoking a moving `@latest` would read whatever the registry served
|
|
723
|
+
* at that instant, so the version reported and the version inspected could
|
|
724
|
+
* differ, and the gate would be comparing an identity to the wrong build.
|
|
725
|
+
*
|
|
726
|
+
* Every failure is `unavailable`, never a mismatch: not knowing what was
|
|
727
|
+
* published is a different fact from knowing it is wrong, and only the second
|
|
728
|
+
* may block a run.
|
|
729
|
+
*/
|
|
730
|
+
export async function readPublishedBuildIdentity(deps) {
|
|
731
|
+
// BAPI-944: the fixture short-circuits BOTH the registry resolver and the
|
|
732
|
+
// `npx` probe below — checked before either is touched, so a spawned
|
|
733
|
+
// integration child with the sentinel set never reaches the network or the
|
|
734
|
+
// package manager.
|
|
735
|
+
if (deps.publishedIdentityFixture !== undefined) {
|
|
736
|
+
return deps.publishedIdentityFixture;
|
|
737
|
+
}
|
|
738
|
+
const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
|
|
739
|
+
let version;
|
|
740
|
+
try {
|
|
741
|
+
version = await resolveVersion();
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return { kind: "unavailable", reason: "registry_unreadable" };
|
|
745
|
+
}
|
|
746
|
+
if (typeof version !== "string" || version.trim().length === 0) {
|
|
747
|
+
return { kind: "unavailable", reason: "registry_unreadable" };
|
|
748
|
+
}
|
|
749
|
+
const resolvedVersion = version.trim();
|
|
750
|
+
let probe;
|
|
751
|
+
try {
|
|
752
|
+
probe = await deps.runCommand("npx", ["-y", `${MCP_PACKAGE_NAME}@${resolvedVersion}`, "--version"], { cwd: deps.cwd, timeoutMs: PUBLISHED_IDENTITY_TIMEOUT_MS });
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
return { kind: "unavailable", reason: "launch_failed" };
|
|
756
|
+
}
|
|
757
|
+
if (!probe || probe.exitCode !== 0) {
|
|
758
|
+
return { kind: "unavailable", reason: "launch_failed" };
|
|
759
|
+
}
|
|
760
|
+
const lines = String(probe.stdout ?? "")
|
|
761
|
+
.split("\n")
|
|
762
|
+
.map((line) => line.trim())
|
|
763
|
+
.filter((line) => line.length > 0);
|
|
764
|
+
// The first line is the semver contract `--version` has always emitted. It
|
|
765
|
+
// must be the version we asked for, or the output does not describe the build
|
|
766
|
+
// this reader resolved.
|
|
767
|
+
if (lines[0] !== resolvedVersion) {
|
|
768
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
769
|
+
}
|
|
770
|
+
const commitLine = lines.slice(1).find((line) => line.startsWith("commit:"));
|
|
771
|
+
if (commitLine === undefined) {
|
|
772
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
773
|
+
}
|
|
774
|
+
if (commitLine === `commit: ${PUBLISHED_IDENTITY_UNKNOWN}`) {
|
|
775
|
+
return { kind: "unavailable", reason: "identity_unknown" };
|
|
776
|
+
}
|
|
777
|
+
const match = PUBLISHED_IDENTITY_PATTERN.exec(commitLine);
|
|
778
|
+
if (match === null) {
|
|
779
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
780
|
+
}
|
|
781
|
+
return { kind: "known", version: resolvedVersion, commit: match[1], dirty: match[2] !== undefined };
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Decide whether the PUBLISHED build carries the code this epic will be cut at.
|
|
785
|
+
*
|
|
786
|
+
* The gate is expressed as CONTAINMENT, not as a version floor and not as an
|
|
787
|
+
* exact-commit match. "The published build is at least as new as the commit we
|
|
788
|
+
* are conducting" is the property that actually matters, and it is the property
|
|
789
|
+
* a version number could never express: the same semver spanned three different
|
|
790
|
+
* contents, which is why the old floor was unverifiable.
|
|
791
|
+
*
|
|
792
|
+
* Blocking and fail-open are separated deliberately. Knowing the published build
|
|
793
|
+
* is wrong blocks. NOT knowing what was published — a registry outage, a cold
|
|
794
|
+
* npx launch that failed, a build with no git metadata — is an advisory, because
|
|
795
|
+
* a network problem must never stop a run.
|
|
796
|
+
*/
|
|
797
|
+
export async function evaluatePublishGate(deps, expectedCommitSha) {
|
|
798
|
+
if (expectedCommitSha === null) {
|
|
799
|
+
// The canonical-index check already recorded its own failure; adding a
|
|
800
|
+
// second one for the same root cause only pads the report.
|
|
801
|
+
return {
|
|
802
|
+
failures: [],
|
|
803
|
+
advisories: [
|
|
804
|
+
"advisory: the publish gate was not evaluated because no canonical indexed commit is available to check against.",
|
|
805
|
+
],
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const expected = normalizeCommitSha(expectedCommitSha);
|
|
809
|
+
if (expected === null) {
|
|
810
|
+
return {
|
|
811
|
+
failures: [
|
|
812
|
+
"The publish gate cannot be evaluated: the expected commit is not a full 40-character SHA. " +
|
|
813
|
+
"Refusing rather than comparing an arbitrary prefix.",
|
|
814
|
+
],
|
|
815
|
+
advisories: [],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
const identity = await readPublishedBuildIdentity(deps);
|
|
819
|
+
if (identity.kind === "unavailable") {
|
|
820
|
+
return {
|
|
821
|
+
failures: [],
|
|
822
|
+
advisories: [
|
|
823
|
+
`advisory: the publish gate could not be verified — ${describePublishedIdentityReason(identity.reason)}. ` +
|
|
824
|
+
`Initialization is continuing; the published ${MCP_PACKAGE_NAME} build was NOT confirmed to contain ${expected}.`,
|
|
825
|
+
],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (identity.dirty) {
|
|
829
|
+
return {
|
|
830
|
+
failures: [
|
|
831
|
+
`The published ${MCP_PACKAGE_NAME}@${identity.version} reports build commit ${identity.commit}-dirty. ` +
|
|
832
|
+
"A dirty build carries content that no commit represents, so it cannot be verified to contain " +
|
|
833
|
+
`${expected}. Publish a build from a clean checkout.`,
|
|
834
|
+
],
|
|
835
|
+
advisories: [],
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// The published SHA must be an object THIS checkout knows about before any
|
|
839
|
+
// ancestry claim is possible. It is a short SHA, so it cannot be fetched by
|
|
840
|
+
// name — an unresolvable one is "cannot verify", never "wrong".
|
|
841
|
+
const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${identity.commit}^{commit}`]);
|
|
842
|
+
if (present.exitCode !== 0) {
|
|
843
|
+
return {
|
|
844
|
+
failures: [],
|
|
845
|
+
advisories: [
|
|
846
|
+
`advisory: the published ${MCP_PACKAGE_NAME}@${identity.version} build commit ${identity.commit} ` +
|
|
847
|
+
"is not present in this checkout, so the publish gate could not be verified. " +
|
|
848
|
+
`Initialization is continuing; fetch origin and confirm that build contains ${expected}.`,
|
|
849
|
+
],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const contains = await git(deps, ["merge-base", "--is-ancestor", expected, identity.commit]);
|
|
853
|
+
if (contains.exitCode === 0)
|
|
854
|
+
return { failures: [], advisories: [] };
|
|
855
|
+
if (contains.exitCode === 1) {
|
|
856
|
+
return {
|
|
857
|
+
failures: [
|
|
858
|
+
`The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
|
|
859
|
+
`which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
|
|
860
|
+
"Publish a build containing that commit before initializing. Since BAPI-945 the " +
|
|
861
|
+
"publisher is CI, not a laptop: from a clean, current `main` checkout run " +
|
|
862
|
+
"`cd mcp_server && npm version patch`, then `git push origin main` and " +
|
|
863
|
+
'`git push origin "mcp-server/v<version>"` — that tag push triggers the ' +
|
|
864
|
+
"clean-room release workflow. There is deliberately no --force or --override here.",
|
|
865
|
+
],
|
|
866
|
+
advisories: [],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
failures: [],
|
|
871
|
+
advisories: [
|
|
872
|
+
`advisory: the publish gate could not be verified — the ancestry of published build commit ` +
|
|
873
|
+
`${identity.commit} could not be determined locally. Initialization is continuing.`,
|
|
874
|
+
],
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Run every independent `init` check and ACCUMULATE the failures.
|
|
879
|
+
*
|
|
880
|
+
* The accumulation is the feature. A first-failure-wins preflight turns a
|
|
881
|
+
* five-problem environment into five invocations, each revealing one problem,
|
|
882
|
+
* and the operator fixes them in the order the code happens to check rather than
|
|
883
|
+
* the order that matters. Checks that genuinely depend on an earlier result
|
|
884
|
+
* (everything needing credentials, everything needing a base branch) are skipped
|
|
885
|
+
* with their own recorded failure rather than crashing.
|
|
886
|
+
*
|
|
887
|
+
* NOTHING here mutates: no push, no repoint, no checkpoint write, no lock, and
|
|
888
|
+
* no supervisor-config PUT. The one write this function used to perform — turning
|
|
889
|
+
* `auto_merge_enabled` on — is returned as {@link ConductEpicInitPreflight.pendingSupervisorConfig}
|
|
890
|
+
* for the caller to execute on the success path, because a preflight that
|
|
891
|
+
* accumulates failures must not have already changed a project-wide default by
|
|
892
|
+
* the time it refuses.
|
|
893
|
+
*/
|
|
894
|
+
export async function collectConductEpicInitPreflight(deps, options) {
|
|
895
|
+
const failures = [];
|
|
896
|
+
const announcements = [];
|
|
897
|
+
const advisories = [];
|
|
898
|
+
const epicBranch = epicBranchFor(options.epicKey);
|
|
899
|
+
let pendingSupervisorConfig = null;
|
|
900
|
+
// (1) gh authentication.
|
|
901
|
+
try {
|
|
902
|
+
const gh = await deps.runGh(["auth", "status"], { cwd: deps.cwd });
|
|
903
|
+
if (!gh.ok)
|
|
904
|
+
failures.push("gh is not authenticated. Run `gh auth login`.");
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
failures.push("gh could not be run. Install the GitHub CLI and run `gh auth login`.");
|
|
908
|
+
}
|
|
909
|
+
// (2) Worktrunk, honoring BAPI_WORKTRUNK_BIN.
|
|
910
|
+
const worktrunk = resolveWorktrunkBinary(deps.platform, deps.env);
|
|
911
|
+
try {
|
|
912
|
+
const probe = await deps.runCommand(worktrunk, ["--version"], { cwd: deps.cwd });
|
|
913
|
+
if (probe.exitCode !== 0) {
|
|
914
|
+
failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
|
|
919
|
+
}
|
|
920
|
+
// (3) Bridge credentials.
|
|
921
|
+
const accessResult = await resolveAccess(deps);
|
|
922
|
+
const access = accessResult.ok ? accessResult.access : null;
|
|
923
|
+
if (!accessResult.ok)
|
|
924
|
+
failures.push(accessResult.error);
|
|
925
|
+
let baseBranch = options.baseBranch ?? null;
|
|
926
|
+
let baseSha = null;
|
|
927
|
+
let cutCommitSha = null;
|
|
928
|
+
let epicBranchAlreadyAtCut = false;
|
|
929
|
+
if (access !== null) {
|
|
930
|
+
// (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
|
|
931
|
+
const readiness = await getConductorReadiness(access, deps.fetchImpl);
|
|
932
|
+
if (!readiness.ok) {
|
|
933
|
+
failures.push(`Conductor readiness could not be read: ${readiness.error}`);
|
|
934
|
+
}
|
|
935
|
+
else if (!readiness.value.supervisor.auto_merge_enabled) {
|
|
936
|
+
const current = await getEffectiveSupervisorConfig(access, deps.fetchImpl);
|
|
937
|
+
if (!current.ok) {
|
|
938
|
+
failures.push(`auto_merge_enabled is not set and the effective supervisor config could not be read: ${current.error}`);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
// Wholesale upsert: copy every field, flip exactly one. Omitting a field
|
|
942
|
+
// would reset it to the request model's default.
|
|
943
|
+
const body = {
|
|
944
|
+
teardown_enabled: current.value.teardown_enabled,
|
|
945
|
+
auto_rereview_enabled: current.value.auto_rereview_enabled,
|
|
946
|
+
max_remediation_attempts: current.value.max_remediation_attempts,
|
|
947
|
+
max_remediation_no_progress_attempts: current.value.max_remediation_no_progress_attempts,
|
|
948
|
+
worker_liveness_window_seconds: current.value.worker_liveness_window_seconds,
|
|
949
|
+
auto_merge_enabled: true,
|
|
950
|
+
merge_approval_required: current.value.merge_approval_required,
|
|
951
|
+
};
|
|
952
|
+
if (options.dryRun) {
|
|
953
|
+
announcements.push(`announced: would enable auto_merge_enabled on the ${access.repoName} project defaults (--dry-run: not sent).`);
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
// Deferred, not sent. The success announcement is added by
|
|
957
|
+
// `runConductEpicInit` only after the PUT actually succeeds, so the
|
|
958
|
+
// `announced:` line never claims a settings change that did not happen.
|
|
959
|
+
pendingSupervisorConfig = body;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// (5) Required checks. `resolveCiChecks` is called EXACTLY ONCE regardless of
|
|
964
|
+
// the readiness shortcut, because its side effect — warming the
|
|
965
|
+
// `poll-ci-checks` cache — is what makes the first `status` poll answer at
|
|
966
|
+
// all. Skipping it when readiness already looks healthy is the bug that
|
|
967
|
+
// makes tick 1 report `available:false` for no reason.
|
|
968
|
+
const resolved = await resolveCiChecks(access, undefined, deps.fetchImpl);
|
|
969
|
+
const resolvedRequired = requiredCheckNamesFromResolve(resolved.ok ? resolved.value : null);
|
|
970
|
+
if (!readiness.ok) {
|
|
971
|
+
// Already recorded above; nothing further can be concluded.
|
|
972
|
+
}
|
|
973
|
+
else if (readiness.value.supervisor.required_checks_empty) {
|
|
974
|
+
if (!resolved.ok) {
|
|
975
|
+
failures.push(`No required CI checks are configured and resolve-ci-checks failed: ${resolved.error}`);
|
|
976
|
+
}
|
|
977
|
+
else if (resolvedRequired.length === 0) {
|
|
978
|
+
failures.push("No required CI checks are configured and resolve-ci-checks returned none. " +
|
|
979
|
+
"A done gate with an empty required set passes vacuously.");
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// (6) No active server-side epic run for this key.
|
|
983
|
+
const runState = await getEpicRunState(access, options.epicKey, deps.fetchImpl);
|
|
984
|
+
if (runState.ok) {
|
|
985
|
+
const status = runState.value.epic_run?.status;
|
|
986
|
+
if (status !== "done" && status !== "abandoned") {
|
|
987
|
+
failures.push(`A server-side epic run for ${options.epicKey} is ${String(status)}. ` +
|
|
988
|
+
"implement-epic will not drive an epic the v2 reconciler is already conducting.");
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
else if (runState.status !== 404) {
|
|
992
|
+
failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
|
|
993
|
+
}
|
|
994
|
+
// (10) BAPI-847: there is no repository-wide index-branch override to check
|
|
995
|
+
// any more. An epic no longer takes the repository's index away from anyone —
|
|
996
|
+
// it gets its OWN index scope — so two epics running at once is an ordinary
|
|
997
|
+
// state rather than a conflict a preflight has to detect. The default-base
|
|
998
|
+
// resolution below therefore never reads a stored "original" branch: nothing
|
|
999
|
+
// was ever repointed, so the repository's configured base IS the original.
|
|
1000
|
+
// (11) The canonical index must have a usable commit (BAPI-843). This is the
|
|
1001
|
+
// check that inverts the cut order: without a `succeeded` canonical parse
|
|
1002
|
+
// publishing a commit, there is no commit to cut at, and cutting at the base
|
|
1003
|
+
// tip instead is exactly the behavior this replaces. The failure text names
|
|
1004
|
+
// the fix an operator can actually perform.
|
|
1005
|
+
const parseStatus = await getParseStatus(access, deps.fetchImpl);
|
|
1006
|
+
if (!parseStatus.ok) {
|
|
1007
|
+
failures.push(`The canonical parse status could not be read: ${parseStatus.error}`);
|
|
1008
|
+
}
|
|
1009
|
+
else if (parseStatus.value.status !== "succeeded") {
|
|
1010
|
+
failures.push(`The canonical index for ${access.repoName} has no successful parse ` +
|
|
1011
|
+
`(status: ${String(parseStatus.value.status)}). Parse the repository first.`);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
const indexed = normalizeCommitSha(parseStatus.value.indexed_commit_sha);
|
|
1015
|
+
if (indexed === null) {
|
|
1016
|
+
failures.push(`The canonical index for ${access.repoName} published no commit for its ` +
|
|
1017
|
+
"last successful parse, so there is no commit to cut at. " +
|
|
1018
|
+
"Parse the repository first.");
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
cutCommitSha = indexed;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
// (8) default base, continued: the configured base branch, then `main`.
|
|
1025
|
+
if (baseBranch === null) {
|
|
1026
|
+
const configured = await getConfigFieldBaseBranch(access, deps.fetchImpl);
|
|
1027
|
+
if (configured.ok && configured.value.base_branch !== null) {
|
|
1028
|
+
baseBranch = configured.value.base_branch;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (baseBranch === null)
|
|
1033
|
+
baseBranch = "main";
|
|
1034
|
+
const branchReason = validateBranchName(baseBranch);
|
|
1035
|
+
if (branchReason) {
|
|
1036
|
+
failures.push(`The resolved base branch is unusable: ${branchReason}`);
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
// (8) The base is still fetched — local git needs its object history, and the
|
|
1040
|
+
// cut commit is almost always reachable from it — but the base tip is NO
|
|
1041
|
+
// LONGER the branch source (BAPI-843). It is reported for context only.
|
|
1042
|
+
const fetched = await git(deps, ["fetch", "origin", baseBranch]);
|
|
1043
|
+
if (fetched.exitCode !== 0) {
|
|
1044
|
+
failures.push(`git fetch origin ${baseBranch} failed.`);
|
|
1045
|
+
}
|
|
1046
|
+
const tip = await git(deps, ["rev-parse", `refs/remotes/origin/${baseBranch}`]);
|
|
1047
|
+
baseSha = tip.exitCode === 0 ? firstLine(tip) : null;
|
|
1048
|
+
if (baseSha === null) {
|
|
1049
|
+
failures.push(`origin/${baseBranch} does not exist after fetching.`);
|
|
1050
|
+
}
|
|
1051
|
+
if (cutCommitSha !== null) {
|
|
1052
|
+
// (12) The EXACT cut object must be resolvable locally, because `init`
|
|
1053
|
+
// pushes it by SHA. The base fetch usually brings it along; when it did
|
|
1054
|
+
// not — the index covers a commit that is no longer an ancestor of the
|
|
1055
|
+
// base tip — one targeted, NON-MUTATING fetch of that SHA is attempted (it
|
|
1056
|
+
// updates no ref, creates no branch, and checks nothing out). Failing here
|
|
1057
|
+
// is deliberate: it happens before the cut protocol, so nothing has been
|
|
1058
|
+
// held, pushed, or recorded.
|
|
1059
|
+
const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
|
|
1060
|
+
if (present.exitCode !== 0) {
|
|
1061
|
+
await git(deps, ["fetch", "origin", cutCommitSha]);
|
|
1062
|
+
const retry = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
|
|
1063
|
+
if (retry.exitCode !== 0) {
|
|
1064
|
+
failures.push(`The canonical indexed commit ${cutCommitSha} could not be resolved locally ` +
|
|
1065
|
+
`even after fetching it from origin. Fetch it manually, or re-parse ${access?.repoName ?? "the repository"}.`);
|
|
1066
|
+
cutCommitSha = null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// (13) BAPI-873: the publish gate. Evaluated HERE — after the cut commit is
|
|
1071
|
+
// known and proven present locally, and still before anything mutates —
|
|
1072
|
+
// because the gate's question is whether the PUBLISHED package (the one
|
|
1073
|
+
// `start-tickets` spawns for every worker) already carries the code this
|
|
1074
|
+
// epic is cut at. A readable mismatch joins `failures` so it is reported
|
|
1075
|
+
// alongside every other readiness problem; an unreadable published identity
|
|
1076
|
+
// becomes an advisory and the run continues.
|
|
1077
|
+
const publishGate = await evaluatePublishGate(deps, cutCommitSha);
|
|
1078
|
+
failures.push(...publishGate.failures);
|
|
1079
|
+
advisories.push(...publishGate.advisories);
|
|
1080
|
+
// (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
|
|
1081
|
+
// canonical indexed commit. An epic branch sitting at ANY other commit still
|
|
1082
|
+
// fails closed — including the base tip, which is no longer special.
|
|
1083
|
+
const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
|
|
1084
|
+
if (existing.exitCode !== 0) {
|
|
1085
|
+
failures.push(`git ls-remote could not read origin/${epicBranch}.`);
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
const existingSha = lsRemoteSha(existing);
|
|
1089
|
+
if (existingSha !== null) {
|
|
1090
|
+
if (cutCommitSha !== null && existingSha === cutCommitSha) {
|
|
1091
|
+
epicBranchAlreadyAtCut = true;
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
failures.push(`origin/${epicBranch} already exists at ${existingSha}, which is not the ` +
|
|
1095
|
+
`canonical indexed commit${cutCommitSha ? ` ${cutCommitSha}` : ""}. ` +
|
|
1096
|
+
"Delete it or finish the previous run before re-initializing.");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// (7) The lock must be free or recoverably dead. Inspected READ-ONLY here;
|
|
1102
|
+
// acquisition happens only after every check passes.
|
|
1103
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
1104
|
+
const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1105
|
+
if (lockState.kind === "live-foreign") {
|
|
1106
|
+
failures.push(`The epic lock is held by live process ${lockState.owner.owner_pid} on ${lockState.owner.host}.`);
|
|
1107
|
+
}
|
|
1108
|
+
else if (lockState.kind === "remote-host") {
|
|
1109
|
+
failures.push(`The epic lock was taken on host ${lockState.owner.host} and cannot be recovered here.`);
|
|
1110
|
+
}
|
|
1111
|
+
else if (lockState.kind === "unknown") {
|
|
1112
|
+
failures.push(`The epic lock is unusable: ${lockState.reason}`);
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
failures,
|
|
1116
|
+
announcements,
|
|
1117
|
+
advisories,
|
|
1118
|
+
access,
|
|
1119
|
+
baseBranch,
|
|
1120
|
+
baseSha,
|
|
1121
|
+
cutCommitSha,
|
|
1122
|
+
epicBranchAlreadyAtCut,
|
|
1123
|
+
pendingSupervisorConfig,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/** Extract required check NAMES from a `resolve-ci-checks` soft envelope. */
|
|
1127
|
+
function requiredCheckNamesFromResolve(value) {
|
|
1128
|
+
if (!isRecord(value))
|
|
1129
|
+
return [];
|
|
1130
|
+
const detail = value["detail"];
|
|
1131
|
+
if (!isRecord(detail))
|
|
1132
|
+
return [];
|
|
1133
|
+
const checks = detail["checks"];
|
|
1134
|
+
if (!Array.isArray(checks))
|
|
1135
|
+
return [];
|
|
1136
|
+
const names = [];
|
|
1137
|
+
for (const check of checks) {
|
|
1138
|
+
if (!isRecord(check))
|
|
1139
|
+
continue;
|
|
1140
|
+
if (check["required"] !== true)
|
|
1141
|
+
continue;
|
|
1142
|
+
const name = check["name"];
|
|
1143
|
+
if (typeof name === "string" && name.trim().length > 0)
|
|
1144
|
+
names.push(name.trim());
|
|
1145
|
+
}
|
|
1146
|
+
return names;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Drive the scope from a recorded cut to `ready`, or report why it did not.
|
|
1150
|
+
*
|
|
1151
|
+
* Schedules the bootstrap (seed + verifying parse) and then POLLS the control
|
|
1152
|
+
* plane, because readiness is a server-side fact this CLI observes rather than
|
|
1153
|
+
* one it can conclude from its own request succeeding.
|
|
1154
|
+
*
|
|
1155
|
+
* Each terminal state maps to its own operator instruction, and the distinction
|
|
1156
|
+
* matters: `canonical_index_advanced` means re-run `init` (the cut protocol
|
|
1157
|
+
* re-drives at the newer commit), while a verification failure means the seed
|
|
1158
|
+
* itself is wrong and re-cutting would just reproduce it.
|
|
1159
|
+
*/
|
|
1160
|
+
async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
|
|
1161
|
+
const scheduled = await bootstrapIndexScope(access, { scopeId }, deps.fetchImpl);
|
|
1162
|
+
if (!scheduled.ok) {
|
|
1163
|
+
return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
|
|
1164
|
+
}
|
|
1165
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1166
|
+
// BAPI-963. The seed printed NOTHING for ~30 minutes during the BAPI-943 pilot:
|
|
1167
|
+
// no output, no checkpoint, and a frozen `epic_shadow_index.updated_at`, so a
|
|
1168
|
+
// healthy seed and a wedge looked identical from outside. Because the seed
|
|
1169
|
+
// copies the canonical index wholesale rather than re-parsing, no
|
|
1170
|
+
// `repository_parse_runs` row appears either, removing the last progress
|
|
1171
|
+
// signal. The notice and the per-poll heartbeat below are the fix.
|
|
1172
|
+
//
|
|
1173
|
+
// The MECHANISM (elapsed computation, the line shape, the window description)
|
|
1174
|
+
// lives at the shared `cut-protocol.ts` seam that `setup-epic` also drives, so
|
|
1175
|
+
// v2 can adopt the same progress observation; the RENDERING stays here, on the
|
|
1176
|
+
// pilot's own stderr advisory channel, which is what keeps stdout exactly one
|
|
1177
|
+
// JSON object under `--json`.
|
|
1178
|
+
const startedAtMs = deps.now().getTime();
|
|
1179
|
+
deps.errorLog(describeScopeBootstrapWindow(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_MAX_POLLS));
|
|
1180
|
+
let lastState = "unknown";
|
|
1181
|
+
for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
|
|
1182
|
+
await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
|
|
1183
|
+
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
1184
|
+
const elapsedMs = deps.now().getTime() - startedAtMs;
|
|
1185
|
+
if (!status.ok) {
|
|
1186
|
+
// A transient read failure is not a verdict: keep polling and let the
|
|
1187
|
+
// bound below be the thing that gives up. The heartbeat reports the fixed
|
|
1188
|
+
// `unreadable` label rather than the raw error, which stays out of a line
|
|
1189
|
+
// that repeats every interval.
|
|
1190
|
+
deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE));
|
|
1191
|
+
lastState = `unreadable (${status.error})`;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, status.value.lifecycle_state));
|
|
1195
|
+
lastState = status.value.lifecycle_state;
|
|
1196
|
+
if (status.value.lifecycle_state === "ready") {
|
|
1197
|
+
if (status.value.indexed_commit_sha !== null &&
|
|
1198
|
+
status.value.indexed_commit_sha === status.value.cut_commit_sha) {
|
|
1199
|
+
return { ok: true, failures: [] };
|
|
1200
|
+
}
|
|
1201
|
+
// `ready` is the server's verdict, and the server only promotes a scope
|
|
1202
|
+
// whose watermark matches. Disagreeing here would mean the control plane
|
|
1203
|
+
// contradicted itself, which is worth refusing rather than proceeding.
|
|
1204
|
+
return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
failures: [
|
|
1207
|
+
`Index scope ${scopeId} reports ready but its indexed commit ` +
|
|
1208
|
+
`(${status.value.indexed_commit_sha ?? "none"}) is not the cut commit ` +
|
|
1209
|
+
`(${status.value.cut_commit_sha ?? "none"}).`,
|
|
1210
|
+
],
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
if (status.value.lifecycle_state === "failed") {
|
|
1214
|
+
const reason = status.value.last_error ?? "unknown";
|
|
1215
|
+
if (reason === "canonical_index_advanced") {
|
|
1216
|
+
return {
|
|
1217
|
+
ok: false,
|
|
1218
|
+
failures: [
|
|
1219
|
+
`The canonical index advanced before the seed could run, so the scope was not seeded. ` +
|
|
1220
|
+
`Delete origin/${epicBranchFor(options.epicKey)} and re-run init to cut at the newer commit.`,
|
|
1221
|
+
],
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
failures: [
|
|
1227
|
+
`Index scope ${scopeId} failed verification (${reason}). ` +
|
|
1228
|
+
"The epic branch and its recorded cut are intact; re-run init to re-drive verification.",
|
|
1229
|
+
],
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
ok: false,
|
|
1235
|
+
failures: [
|
|
1236
|
+
`TIMED OUT: index scope ${scopeId} did not become ready within the bootstrap window ` +
|
|
1237
|
+
`(last observed state: ${lastState}). Re-run init to resume verification.`,
|
|
1238
|
+
],
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* `implement-epic init` — the only verb that provisions.
|
|
1243
|
+
*
|
|
1244
|
+
* Refuses a second run outright: an existing checkpoint means an epic is already
|
|
1245
|
+
* under way, and re-initializing would discard its ticket statuses and counters.
|
|
1246
|
+
* That refusal deliberately applies to a CORRUPT existing checkpoint too — the
|
|
1247
|
+
* bytes may be all that is left of a half-finished run, and overwriting them as
|
|
1248
|
+
* "repair" is worse than making a human look.
|
|
1249
|
+
*/
|
|
1250
|
+
export async function runConductEpicInit(deps, options) {
|
|
1251
|
+
// Credential resolution is the PREFLIGHT's job (check 3). Probing here as well
|
|
1252
|
+
// would resolve the credential twice and report a failure the preflight is
|
|
1253
|
+
// about to report properly, so `init` reads the access it established instead.
|
|
1254
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
1255
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
1256
|
+
const epicBranch = epicBranchFor(options.epicKey);
|
|
1257
|
+
const existing = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
1258
|
+
if (existing.kind !== "missing") {
|
|
1259
|
+
return emitFailure(deps, options.json, [
|
|
1260
|
+
`${options.epicKey} is already initialized: a checkpoint exists at ${checkpointPath}. ` +
|
|
1261
|
+
"Nothing was changed.",
|
|
1262
|
+
], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
|
|
1263
|
+
}
|
|
1264
|
+
const preflight = await collectConductEpicInitPreflight(deps, options);
|
|
1265
|
+
// BAPI-873: fail-open advisories are reported before the outcome is decided,
|
|
1266
|
+
// on stderr, on both paths — an advisory that only printed on failure would
|
|
1267
|
+
// let a run proceed silently past an unverified publish gate.
|
|
1268
|
+
for (const line of preflight.advisories)
|
|
1269
|
+
deps.errorLog(line);
|
|
1270
|
+
if (preflight.failures.length > 0) {
|
|
1271
|
+
for (const line of preflight.announcements)
|
|
1272
|
+
deps.errorLog(line);
|
|
1273
|
+
return emitFailure(deps, options.json, preflight.failures, {
|
|
1274
|
+
epic_key: options.epicKey,
|
|
1275
|
+
checkpoint_path: checkpointPath,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const access = preflight.access;
|
|
1279
|
+
if (access === null ||
|
|
1280
|
+
preflight.baseBranch === null ||
|
|
1281
|
+
preflight.baseSha === null ||
|
|
1282
|
+
preflight.cutCommitSha === null) {
|
|
1283
|
+
// Unreachable: any of these being absent records a failure above. Guarded so
|
|
1284
|
+
// a future edit cannot turn a missing precondition into a push — in
|
|
1285
|
+
// particular a missing `cutCommitSha`, which would otherwise be a push at
|
|
1286
|
+
// `undefined`.
|
|
1287
|
+
return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
|
|
1288
|
+
epic_key: options.epicKey,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
const cutCommitSha = preflight.cutCommitSha;
|
|
1292
|
+
// Starts as the preflight's own lines (which in `--dry-run` already include the
|
|
1293
|
+
// would-enable notice) and grows by at most the one success line below.
|
|
1294
|
+
const announcements = [...preflight.announcements];
|
|
1295
|
+
// A function, not a value: the deterministic plan-line ordering is fixed, but
|
|
1296
|
+
// the trailing announcements are only final once the deferred PUT has settled.
|
|
1297
|
+
const describePlan = () => [
|
|
1298
|
+
`epic: ${options.epicKey}`,
|
|
1299
|
+
`repo: ${access.repoName}`,
|
|
1300
|
+
// The base branch and the cut commit are reported SEPARATELY and never
|
|
1301
|
+
// conflated: the base is context (and the object history git needs), the cut
|
|
1302
|
+
// is the commit the epic actually starts from.
|
|
1303
|
+
`base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
|
|
1304
|
+
`cut: ${cutCommitSha} (canonical indexed commit)`,
|
|
1305
|
+
`branch: ${epicBranch}${preflight.epicBranchAlreadyAtCut ? " (already at the cut commit)" : ""}`,
|
|
1306
|
+
`tickets: ${options.tickets.join(", ")}`,
|
|
1307
|
+
`checkpoint: ${checkpointPath}`,
|
|
1308
|
+
...announcements,
|
|
1309
|
+
];
|
|
1310
|
+
if (options.dryRun) {
|
|
1311
|
+
return emitSuccess(deps, options.json, {
|
|
1312
|
+
ok: true,
|
|
1313
|
+
dry_run: true,
|
|
1314
|
+
epic_key: options.epicKey,
|
|
1315
|
+
epic_branch: epicBranch,
|
|
1316
|
+
base_branch: preflight.baseBranch,
|
|
1317
|
+
base_sha: preflight.baseSha,
|
|
1318
|
+
cut_commit_sha: cutCommitSha,
|
|
1319
|
+
tickets: options.tickets,
|
|
1320
|
+
checkpoint_path: checkpointPath,
|
|
1321
|
+
announcements,
|
|
1322
|
+
}, [
|
|
1323
|
+
"Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):",
|
|
1324
|
+
...describePlan(),
|
|
1325
|
+
`would open a draft pull request ${epicBranch} → ${preflight.baseBranch} for ` +
|
|
1326
|
+
"conductor-ci / gate (BAPI-951; nothing opened in dry-run)",
|
|
1327
|
+
]);
|
|
1328
|
+
}
|
|
1329
|
+
// The FIRST durable mutation of the whole verb, deliberately placed here: every
|
|
1330
|
+
// preflight check has passed, and nothing has been pushed, repointed, written,
|
|
1331
|
+
// or locked yet. A failed PUT therefore leaves the environment exactly as `init`
|
|
1332
|
+
// found it.
|
|
1333
|
+
if (preflight.pendingSupervisorConfig !== null) {
|
|
1334
|
+
const put = await putSupervisorConfigDefaults(access, preflight.pendingSupervisorConfig, deps.fetchImpl);
|
|
1335
|
+
if (!put.ok) {
|
|
1336
|
+
return emitFailure(deps, options.json, [`auto_merge_enabled could not be enabled: ${put.error}`], {
|
|
1337
|
+
epic_key: options.epicKey,
|
|
1338
|
+
checkpoint_path: checkpointPath,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
|
|
1342
|
+
}
|
|
1343
|
+
// --- The cut protocol (BAPI-843) ----------------------------------------
|
|
1344
|
+
//
|
|
1345
|
+
// Driven by the SHARED exact-cut module (BAPI-850): `cut/begin`, the re-check
|
|
1346
|
+
// of `origin/epic/<EPIC>` under the hold, the exact-SHA push with the
|
|
1347
|
+
// operator's own git, the read-back, `cut/commit`, and the release on every
|
|
1348
|
+
// outcome all happen in `performExactIndexScopeCut`, while the SERVER holds
|
|
1349
|
+
// the canonical repository's parse lock so the commit the index covers cannot
|
|
1350
|
+
// move underneath the ref being created. The pilot creates no `epic_run`, so
|
|
1351
|
+
// it passes no run association.
|
|
1352
|
+
const cutOutcome = await performExactIndexScopeCut(cutProtocolDeps(deps), access, {
|
|
1353
|
+
featureBranch: epicBranch,
|
|
1354
|
+
baseBranch: preflight.baseBranch,
|
|
1355
|
+
candidateCommitSha: cutCommitSha,
|
|
1356
|
+
});
|
|
1357
|
+
if (!cutOutcome.ok) {
|
|
1358
|
+
// `begin` refusals carry the checkpoint path (nothing was leased); every
|
|
1359
|
+
// later refusal names only the epic, exactly as before the extraction.
|
|
1360
|
+
return emitFailure(deps, options.json, cutOutcome.failures, cutOutcome.kind === "begin_refused"
|
|
1361
|
+
? { epic_key: options.epicKey, checkpoint_path: checkpointPath }
|
|
1362
|
+
: { epic_key: options.epicKey });
|
|
1363
|
+
}
|
|
1364
|
+
// A successful outcome IS the recorded cut: the shared module returns `ok`
|
|
1365
|
+
// only after `cut/commit` accepted the ref, so there is no unrecorded-cut
|
|
1366
|
+
// state to guard against here.
|
|
1367
|
+
const cut = cutOutcome.lease;
|
|
1368
|
+
// --- Seed + verify, only after the hold is released ----------------------
|
|
1369
|
+
const scopeReady = await driveIndexScopeBootstrap(deps, access, cut.scope_id, options);
|
|
1370
|
+
if (!scopeReady.ok) {
|
|
1371
|
+
return emitFailure(deps, options.json, scopeReady.failures, {
|
|
1372
|
+
epic_key: options.epicKey,
|
|
1373
|
+
scope_id: cut.scope_id,
|
|
1374
|
+
checkpoint_path: checkpointPath,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
|
|
1378
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
1379
|
+
// Only when THIS invocation actually created the branch (`cutOutcome.branchCreated`)
|
|
1380
|
+
// — never on a reused/already-recorded cut, which `init` refuses to reach at all
|
|
1381
|
+
// (an existing checkpoint returns early above). Placed AFTER scope readiness and
|
|
1382
|
+
// BEFORE the checkpoint write, which stays the final mutation.
|
|
1383
|
+
if (cutOutcome.branchCreated) {
|
|
1384
|
+
const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
1385
|
+
epicKey: options.epicKey,
|
|
1386
|
+
epicBranch,
|
|
1387
|
+
baseBranch: preflight.baseBranch,
|
|
1388
|
+
command: "implement-epic init",
|
|
1389
|
+
});
|
|
1390
|
+
announcements.push(`integration pr: ${JSON.stringify(formatted)}`);
|
|
1391
|
+
}
|
|
1392
|
+
// BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
|
|
1393
|
+
// branch here, which is the defect this epic exists to remove — a
|
|
1394
|
+
// repository-wide mutation that every unrelated run then resolved through. The
|
|
1395
|
+
// scope provisioned above already carries the epic branch on its own shadow
|
|
1396
|
+
// config row, so the canonical repository's configuration is never touched and
|
|
1397
|
+
// nothing has to be restored later.
|
|
1398
|
+
//
|
|
1399
|
+
// The checkpoint is still written LAST: it is only correct once the scope has
|
|
1400
|
+
// proven its own coverage.
|
|
1401
|
+
const request = lockRequest(deps);
|
|
1402
|
+
const checkpoint = createInitialConductEpicCheckpoint({
|
|
1403
|
+
epicKey: options.epicKey,
|
|
1404
|
+
repoName: access.repoName,
|
|
1405
|
+
epicBranch,
|
|
1406
|
+
baseBranchOriginal: preflight.baseBranch,
|
|
1407
|
+
// BAPI-844: the server-minted scope from THIS init's cut, recorded so every
|
|
1408
|
+
// later `spawn` declares the scope the epic was actually cut against. It is
|
|
1409
|
+
// stored exactly as returned; nothing local mints, edits, or defaults it.
|
|
1410
|
+
indexScopeId: cut.scope_id,
|
|
1411
|
+
ticketKeys: options.tickets,
|
|
1412
|
+
now: deps.now().toISOString(),
|
|
1413
|
+
lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
|
|
1414
|
+
});
|
|
1415
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, deps.fs, {
|
|
1416
|
+
skipChmod: deps.platform === "win32",
|
|
1417
|
+
});
|
|
1418
|
+
if (!written.ok) {
|
|
1419
|
+
return emitFailure(deps, options.json, [written.error], { epic_key: options.epicKey });
|
|
1420
|
+
}
|
|
1421
|
+
// The lock is taken LAST: a lock without a checkpoint is a state no verb can
|
|
1422
|
+
// interpret, whereas a checkpoint without a lock is simply an unlocked epic.
|
|
1423
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), request, buildConductEpicLockSeams(deps));
|
|
1424
|
+
if (!lock.acquired) {
|
|
1425
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`], {
|
|
1426
|
+
epic_key: options.epicKey,
|
|
1427
|
+
checkpoint_path: checkpointPath,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
return emitSuccess(deps, options.json, {
|
|
1431
|
+
ok: true,
|
|
1432
|
+
epic_key: options.epicKey,
|
|
1433
|
+
epic_branch: epicBranch,
|
|
1434
|
+
base_branch: preflight.baseBranch,
|
|
1435
|
+
base_sha: preflight.baseSha,
|
|
1436
|
+
cut_commit_sha: cutCommitSha,
|
|
1437
|
+
scope_id: cut.scope_id,
|
|
1438
|
+
tickets: options.tickets,
|
|
1439
|
+
checkpoint_path: checkpointPath,
|
|
1440
|
+
lock_path: resolveConductEpicLockPath(checkpointPath),
|
|
1441
|
+
announcements,
|
|
1442
|
+
}, ["Initialized:", ...describePlan()]);
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* `implement-epic status <EPIC> --json` — the ONLY evidence a tick acts on.
|
|
1446
|
+
*
|
|
1447
|
+
* Two rules shape everything below:
|
|
1448
|
+
*
|
|
1449
|
+
* - **Every probe fails open.** `gh` down, CI unreachable, the review endpoint
|
|
1450
|
+
* unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
|
|
1451
|
+
* and the command still exits 0 with a complete object. The loop must be able
|
|
1452
|
+
* to see its own checkpoint during a GitHub outage.
|
|
1453
|
+
* - **The write allowlist is exactly five fields** (`ci_last_poll`,
|
|
1454
|
+
* `last_seen_head`, `last_state_change_at`, a newly discovered
|
|
1455
|
+
* `ticket.branch`, and — since BAPI-963 — an observed `ticket.pr_number`)
|
|
1456
|
+
* plus the normal `updated_at`, alongside the scope's fencing epoch. Everything
|
|
1457
|
+
* else is observational. In particular the checkpoint stores no "expected
|
|
1458
|
+
* head": merge identity always comes from a fresh `pr.head_sha`, and a stored
|
|
1459
|
+
* expectation would be a second source of truth that goes stale.
|
|
1460
|
+
*
|
|
1461
|
+
* `ticket.pr_number` is a SELF-HEAL, not a new source of truth. `status` already
|
|
1462
|
+
* derives the PR from GitHub independently, so the checkpoint field was pure
|
|
1463
|
+
* caller obligation — and when the pilot watcher died during BAPI-943, BAPI-946's
|
|
1464
|
+
* checkpoint still read `pr_number: null` long after PR #1144 had merged, exactly
|
|
1465
|
+
* when the driver had lost track and most needed the durable record.
|
|
1466
|
+
*/
|
|
1467
|
+
export async function runConductEpicStatus(deps, options) {
|
|
1468
|
+
const accessProbe = await resolveAccess(deps);
|
|
1469
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
1470
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
1471
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
1472
|
+
if (read.kind === "missing") {
|
|
1473
|
+
// Exactly four keys. BAPI-804 asserts this shape to decide whether to run
|
|
1474
|
+
// `init`, so extra narration here would be a contract break.
|
|
1475
|
+
return emitSuccess(deps, options.json, {
|
|
1476
|
+
ok: true,
|
|
1477
|
+
epic_key: options.epicKey,
|
|
1478
|
+
checkpoint_path: checkpointPath,
|
|
1479
|
+
checkpoint_exists: false,
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
if (read.kind !== "ok") {
|
|
1483
|
+
return emitFailure(deps, options.json, [read.error], {
|
|
1484
|
+
epic_key: options.epicKey,
|
|
1485
|
+
checkpoint_path: checkpointPath,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
const checkpoint = read.checkpoint;
|
|
1489
|
+
const now = deps.now();
|
|
1490
|
+
const probeErrors = [];
|
|
1491
|
+
const ticket = inFlightTicket(checkpoint);
|
|
1492
|
+
const allDone = ticket === null;
|
|
1493
|
+
// --- worktree + branch discovery -----------------------------------------
|
|
1494
|
+
let discoveredBranch = ticket?.branch ?? null;
|
|
1495
|
+
let worktreePath = null;
|
|
1496
|
+
let worktreeExists = false;
|
|
1497
|
+
if (ticket !== null) {
|
|
1498
|
+
try {
|
|
1499
|
+
const listed = await git(deps, ["worktree", "list", "--porcelain"]);
|
|
1500
|
+
if (listed.exitCode !== 0) {
|
|
1501
|
+
probeErrors.push({ probe: "worktree", reason: "git worktree list failed" });
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticket.key, ticket.branch);
|
|
1505
|
+
if (found !== null) {
|
|
1506
|
+
discoveredBranch = found.branch;
|
|
1507
|
+
worktreePath = found.path;
|
|
1508
|
+
try {
|
|
1509
|
+
await deps.fs.stat(found.path);
|
|
1510
|
+
worktreeExists = true;
|
|
1511
|
+
}
|
|
1512
|
+
catch {
|
|
1513
|
+
worktreeExists = false;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
probeErrors.push({ probe: "worktree", reason: "git worktree list could not be run" });
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
// --- git facts ------------------------------------------------------------
|
|
1523
|
+
let branchHead = null;
|
|
1524
|
+
let workerCommits = 0;
|
|
1525
|
+
if (discoveredBranch !== null) {
|
|
1526
|
+
try {
|
|
1527
|
+
const remote = await git(deps, ["ls-remote", "--heads", "origin", discoveredBranch]);
|
|
1528
|
+
if (remote.exitCode !== 0) {
|
|
1529
|
+
probeErrors.push({ probe: "branch_head", reason: "git ls-remote failed" });
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
branchHead = lsRemoteSha(remote);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
probeErrors.push({ probe: "branch_head", reason: "git ls-remote could not be run" });
|
|
1537
|
+
}
|
|
1538
|
+
if (branchHead !== null) {
|
|
1539
|
+
try {
|
|
1540
|
+
await git(deps, ["fetch", "origin"]);
|
|
1541
|
+
const counted = await git(deps, [
|
|
1542
|
+
"rev-list",
|
|
1543
|
+
"--count",
|
|
1544
|
+
`origin/${checkpoint.epic_branch}..origin/${discoveredBranch}`,
|
|
1545
|
+
]);
|
|
1546
|
+
if (counted.exitCode === 0) {
|
|
1547
|
+
const parsedCount = Number.parseInt(firstLine(counted) ?? "", 10);
|
|
1548
|
+
workerCommits = Number.isSafeInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
|
|
1549
|
+
}
|
|
1550
|
+
else {
|
|
1551
|
+
probeErrors.push({ probe: "worker_commits", reason: "git rev-list failed" });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
catch {
|
|
1555
|
+
probeErrors.push({ probe: "worker_commits", reason: "git rev-list could not be run" });
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
// --- PR state -------------------------------------------------------------
|
|
1560
|
+
let pr = null;
|
|
1561
|
+
if (discoveredBranch !== null) {
|
|
1562
|
+
const probe = await discoverConductEpicPrState(discoveredBranch, {
|
|
1563
|
+
runGh: deps.runGh,
|
|
1564
|
+
cwd: deps.cwd,
|
|
1565
|
+
});
|
|
1566
|
+
if (probe.kind === "ok")
|
|
1567
|
+
pr = probe.pr;
|
|
1568
|
+
else if (probe.kind === "error")
|
|
1569
|
+
probeErrors.push({ probe: "pr", reason: probe.reason });
|
|
1570
|
+
}
|
|
1571
|
+
const mergedExternally = pr?.state === "MERGED" && ticket !== null && ticket.status !== "merged" && ticket.status !== "done";
|
|
1572
|
+
// --- Bridge-backed facts --------------------------------------------------
|
|
1573
|
+
const access = accessProbe.ok ? accessProbe.access : null;
|
|
1574
|
+
if (!accessProbe.ok) {
|
|
1575
|
+
probeErrors.push({ probe: "credentials", reason: accessProbe.error });
|
|
1576
|
+
}
|
|
1577
|
+
let doneGateRequired = null;
|
|
1578
|
+
let reviewOptedIn = false;
|
|
1579
|
+
let reviewSource = null;
|
|
1580
|
+
let reviewDisposition = null;
|
|
1581
|
+
// BAPI-915. An UNREADABLE review policy is not an absent one.
|
|
1582
|
+
//
|
|
1583
|
+
// Before this, an unparseable `done_gate_config` yielded `conditions: []`,
|
|
1584
|
+
// which set `reviewOptedIn = false` — so the pilot read a malformed review
|
|
1585
|
+
// policy as "no review opt-in" and merged on CI alone. That is precisely the
|
|
1586
|
+
// accidental fail-open the Python `INVALID` sentinel exists to prevent, and it
|
|
1587
|
+
// cannot coexist with this ticket's invariant that no malformed input can
|
|
1588
|
+
// select `fail_open`.
|
|
1589
|
+
//
|
|
1590
|
+
// The parser's own `reason` draws the line: `unset` and `disabled` are a
|
|
1591
|
+
// genuine operator opt-out and stay opted out; `malformed` and every
|
|
1592
|
+
// `invalid: …` reason are a policy we could not read, and surface as
|
|
1593
|
+
// opted-in / unavailable / config-invalid so the command's Row 10 handles them
|
|
1594
|
+
// and — having no readable condition — parks under the default disposition.
|
|
1595
|
+
let reviewConfigInvalid = false;
|
|
1596
|
+
if (access !== null) {
|
|
1597
|
+
const setup = await getEffectiveSupervisorSetup(access, deps.fetchImpl);
|
|
1598
|
+
if (!setup.ok) {
|
|
1599
|
+
probeErrors.push({ probe: "supervisor_setup", reason: setup.error });
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
const gate = parseDoneGateConfig(setup.value.done_gate_config);
|
|
1603
|
+
if (gate.reason === "malformed" || gate.reason.startsWith("invalid:")) {
|
|
1604
|
+
reviewConfigInvalid = true;
|
|
1605
|
+
reviewOptedIn = true;
|
|
1606
|
+
// No readable condition exists, so no disposition may be read from one.
|
|
1607
|
+
// `null` leaves the command's effective default at `park`.
|
|
1608
|
+
reviewSource = null;
|
|
1609
|
+
reviewDisposition = null;
|
|
1610
|
+
}
|
|
1611
|
+
for (const condition of gate.conditions) {
|
|
1612
|
+
if (condition.type === "required_ci_checks_green") {
|
|
1613
|
+
doneGateRequired = [...condition.required_checks];
|
|
1614
|
+
}
|
|
1615
|
+
else if (condition.type === "review_state") {
|
|
1616
|
+
reviewOptedIn = true;
|
|
1617
|
+
reviewSource = condition.source;
|
|
1618
|
+
reviewDisposition = condition.verdictless_disposition ?? null;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const ci = access === null || pr?.head_sha == null
|
|
1624
|
+
? null
|
|
1625
|
+
: await collectCiFacts(deps, access, pr.head_sha, doneGateRequired, checkpoint.ci_last_poll, probeErrors);
|
|
1626
|
+
// BAPI-915: the three additional fields are appended to every shape this
|
|
1627
|
+
// sub-object takes, so a consumer never has to branch on whether they exist.
|
|
1628
|
+
const reviewPolicyFacts = {
|
|
1629
|
+
verdictless_disposition: reviewDisposition,
|
|
1630
|
+
verdictless_ceiling: CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING,
|
|
1631
|
+
config_invalid: reviewConfigInvalid,
|
|
1632
|
+
};
|
|
1633
|
+
let review = {
|
|
1634
|
+
opted_in: reviewOptedIn,
|
|
1635
|
+
source: reviewSource,
|
|
1636
|
+
available: null,
|
|
1637
|
+
verdict: null,
|
|
1638
|
+
head_sha: null,
|
|
1639
|
+
...reviewPolicyFacts,
|
|
1640
|
+
};
|
|
1641
|
+
// A config we could not read is reported as opted-in and UNAVAILABLE without
|
|
1642
|
+
// any read being attempted: there is no condition to evaluate a review
|
|
1643
|
+
// against, so `available: false` is the honest answer rather than `null`.
|
|
1644
|
+
if (reviewConfigInvalid) {
|
|
1645
|
+
review = {
|
|
1646
|
+
opted_in: true,
|
|
1647
|
+
source: null,
|
|
1648
|
+
available: false,
|
|
1649
|
+
verdict: null,
|
|
1650
|
+
head_sha: null,
|
|
1651
|
+
...reviewPolicyFacts,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
else if (access !== null && reviewOptedIn && pr?.number != null) {
|
|
1655
|
+
const status = await getPrReviewStatus(access, pr.number, deps.fetchImpl);
|
|
1656
|
+
if (!status.ok) {
|
|
1657
|
+
probeErrors.push({ probe: "review", reason: status.error });
|
|
1658
|
+
review = {
|
|
1659
|
+
opted_in: true,
|
|
1660
|
+
source: reviewSource,
|
|
1661
|
+
available: null,
|
|
1662
|
+
verdict: null,
|
|
1663
|
+
head_sha: null,
|
|
1664
|
+
...reviewPolicyFacts,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
review = {
|
|
1669
|
+
...normalizeReviewStatus(status.value, reviewOptedIn, reviewSource),
|
|
1670
|
+
...reviewPolicyFacts,
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
let parse = null;
|
|
1675
|
+
if (access !== null) {
|
|
1676
|
+
const parseStatus = await getParseStatus(access, deps.fetchImpl);
|
|
1677
|
+
if (!parseStatus.ok) {
|
|
1678
|
+
probeErrors.push({ probe: "parse", reason: parseStatus.error });
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
parse = normalizeParseStatus(parseStatus.value);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
// --- scope (BAPI-845) ----------------------------------------------------
|
|
1685
|
+
// The pilot's freshness evidence, asked DIRECTLY. `parse` above is
|
|
1686
|
+
// repository-level and says nothing about whether *this* merge was indexed,
|
|
1687
|
+
// which is why Row 5 used to reconstruct causality from a request timestamp and
|
|
1688
|
+
// a head SHA. This probe replaces that inference with the scope's own answer:
|
|
1689
|
+
// its lifecycle, both watermarks, and a bounded freshness verdict.
|
|
1690
|
+
//
|
|
1691
|
+
// Absent only when the epic declares no scope — a pilot epic cut before
|
|
1692
|
+
// BAPI-843, or a non-scope run. That is a calm `null` with no probe error,
|
|
1693
|
+
// because there is nothing to report rather than something we failed to read.
|
|
1694
|
+
let scope = null;
|
|
1695
|
+
const declaredScopeId = typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0
|
|
1696
|
+
? checkpoint.index_scope_id
|
|
1697
|
+
: null;
|
|
1698
|
+
if (access !== null && declaredScopeId !== null) {
|
|
1699
|
+
const scopeStatus = await getIndexScopeStatus(access, declaredScopeId, deps.fetchImpl);
|
|
1700
|
+
if (!scopeStatus.ok) {
|
|
1701
|
+
probeErrors.push({ probe: "scope", reason: scopeStatus.error });
|
|
1702
|
+
// An unread scope is reported as explicitly `unavailable` rather than left
|
|
1703
|
+
// null: a null could be read as "no scope declared", and that reading would
|
|
1704
|
+
// let the loop proceed past a freshness question it never got an answer to.
|
|
1705
|
+
scope = {
|
|
1706
|
+
scope_id: declaredScopeId,
|
|
1707
|
+
lifecycle_state: null,
|
|
1708
|
+
freshness_status: "unavailable",
|
|
1709
|
+
blocked_reason: null,
|
|
1710
|
+
required_commit_sha: null,
|
|
1711
|
+
indexed_commit_sha: null,
|
|
1712
|
+
last_error: null,
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
else {
|
|
1716
|
+
scope = {
|
|
1717
|
+
scope_id: scopeStatus.value.scope_id,
|
|
1718
|
+
lifecycle_state: scopeStatus.value.lifecycle_state,
|
|
1719
|
+
// Fail closed: the client already narrowed an unrecognized value to null,
|
|
1720
|
+
// and null here means "not fresh", never "fine".
|
|
1721
|
+
freshness_status: scopeStatus.value.freshness_status ?? "unavailable",
|
|
1722
|
+
blocked_reason: scopeStatus.value.blocked_reason,
|
|
1723
|
+
required_commit_sha: scopeStatus.value.required_commit_sha,
|
|
1724
|
+
indexed_commit_sha: scopeStatus.value.indexed_commit_sha,
|
|
1725
|
+
last_error: scopeStatus.value.last_error,
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
// --- BAPI-963: what a `pending` scope is actually waiting on ----------------
|
|
1730
|
+
//
|
|
1731
|
+
// Read ONLY when the scope is `pending`, because that is the single state where
|
|
1732
|
+
// "who is going to move this?" is the operator's question. A fresh, blocked, or
|
|
1733
|
+
// failed scope is not waiting on a dispatcher, and issuing the request anyway
|
|
1734
|
+
// would add a round trip to every ordinary tick.
|
|
1735
|
+
//
|
|
1736
|
+
// Fail-open like every other status probe: a failed read records a bounded
|
|
1737
|
+
// probe error and reports `unavailable`, which renders the pre-existing
|
|
1738
|
+
// headline unchanged. Absence is claimed only on positive evidence.
|
|
1739
|
+
let dispatcher = null;
|
|
1740
|
+
if (access !== null && scope !== null && scope.freshness_status === "pending") {
|
|
1741
|
+
const health = await getParseDispatcherHealth(access, deps.fetchImpl);
|
|
1742
|
+
if (health.ok) {
|
|
1743
|
+
dispatcher = health.value;
|
|
1744
|
+
}
|
|
1745
|
+
else {
|
|
1746
|
+
probeErrors.push({ probe: "parse_dispatcher", reason: health.error });
|
|
1747
|
+
dispatcher = {
|
|
1748
|
+
observation: "unavailable",
|
|
1749
|
+
heartbeatState: null,
|
|
1750
|
+
respondingSchedulerRunning: false,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
// --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
|
|
1755
|
+
//
|
|
1756
|
+
// TWO distinct jobs, both belonging here rather than in a daemon:
|
|
1757
|
+
//
|
|
1758
|
+
// 1. The LISTING makes a crashed epic's scope discoverable. `scope` above
|
|
1759
|
+
// answers "is MY index fresh?"; this answers "what index scopes exist, and
|
|
1760
|
+
// which of them is stranded?" — including expired, reclaiming, and reclaimed
|
|
1761
|
+
// ones, which is what makes a stale scope actionable without SQL.
|
|
1762
|
+
// 2. The HEARTBEAT renews this epic's lease. The pilot has no long-lived local
|
|
1763
|
+
// process — `/loop 5m /conduct-epic` re-invokes this command on a timer, so
|
|
1764
|
+
// the tick loop IS the heartbeat owner, and its cadence matches the default
|
|
1765
|
+
// heartbeat interval. Beating from here means ownership is renewed exactly
|
|
1766
|
+
// while a conductor is actively driving the epic, and stops the moment it
|
|
1767
|
+
// stops ticking, with no untracked daemon and no shutdown contract to get
|
|
1768
|
+
// wrong.
|
|
1769
|
+
//
|
|
1770
|
+
// Both are probes: a failure lands in `probe_errors` and never fails `status`.
|
|
1771
|
+
let scopes = [];
|
|
1772
|
+
let retentionSeconds = null;
|
|
1773
|
+
let nextLeaseEpoch = checkpoint.index_scope_lease_epoch;
|
|
1774
|
+
if (access !== null) {
|
|
1775
|
+
const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
|
|
1776
|
+
if (!listing.ok) {
|
|
1777
|
+
probeErrors.push({ probe: "scopes", reason: listing.error });
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
scopes = listing.value.scopes;
|
|
1781
|
+
retentionSeconds = listing.value.retention_seconds;
|
|
1782
|
+
}
|
|
1783
|
+
if (declaredScopeId !== null) {
|
|
1784
|
+
// The epoch comes from the SERVER's listing when it is readable, and from
|
|
1785
|
+
// the checkpoint only as a fallback. Preferring the server is what lets a
|
|
1786
|
+
// conductor keep beating after an operator `recover` superseded its
|
|
1787
|
+
// generation — the alternative is a healthy conductor permanently fenced by
|
|
1788
|
+
// a stale local number.
|
|
1789
|
+
const entry = scopes.find((scope) => scope.scope_id === declaredScopeId);
|
|
1790
|
+
const epoch = entry?.lease_epoch ?? checkpoint.index_scope_lease_epoch;
|
|
1791
|
+
if (epoch !== null && entry?.recoverable !== false) {
|
|
1792
|
+
const beat = await heartbeatIndexScope(access, { scopeId: declaredScopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
1793
|
+
if (beat.ok) {
|
|
1794
|
+
nextLeaseEpoch = beat.value.lease_epoch;
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
// A rejected beat means fenced or retired. Recorded as a probe error so
|
|
1798
|
+
// the loop SEES it, not swallowed — but never fatal, because `status`
|
|
1799
|
+
// is also how an operator finds out they were fenced.
|
|
1800
|
+
probeErrors.push({ probe: "scope_heartbeat", reason: beat.error });
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// --- lock (inspected, NEVER acquired) ------------------------------------
|
|
1806
|
+
const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1807
|
+
const lock = {
|
|
1808
|
+
held_by_me: lockState.kind === "owned",
|
|
1809
|
+
owner_pid: "owner" in lockState ? lockState.owner.owner_pid : null,
|
|
1810
|
+
host: "owner" in lockState ? lockState.owner.host : null,
|
|
1811
|
+
alive: lockState.kind === "owned" || lockState.kind === "live-foreign"
|
|
1812
|
+
? true
|
|
1813
|
+
: lockState.kind === "dead-local"
|
|
1814
|
+
? false
|
|
1815
|
+
: null,
|
|
1816
|
+
};
|
|
1817
|
+
// --- the permitted writes ------------------------------------------------
|
|
1818
|
+
// BAPI-846 added the scope's fencing epoch, refreshed from the server's
|
|
1819
|
+
// authoritative answer; BAPI-963 added the observed `ticket.pr_number`. Every
|
|
1820
|
+
// one of them rides in the SAME atomic write rather than in a second one, so a
|
|
1821
|
+
// tick either records everything it observed or nothing.
|
|
1822
|
+
let lastSeenHead = ticket?.last_seen_head ?? null;
|
|
1823
|
+
let lastStateChangeAt = ticket?.last_state_change_at ?? null;
|
|
1824
|
+
// BAPI-963: what the response PROJECTS for the in-flight ticket. It advances to
|
|
1825
|
+
// the mutated clone only after the atomic write proves durable, so the payload
|
|
1826
|
+
// cannot report a self-healed `pr_number` that never reached disk.
|
|
1827
|
+
let projectedTicket = ticket;
|
|
1828
|
+
const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
|
|
1829
|
+
if (ticket !== null) {
|
|
1830
|
+
const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
|
|
1831
|
+
const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
|
|
1832
|
+
let dirty = false;
|
|
1833
|
+
const updatedTicket = { ...next.tickets[index] };
|
|
1834
|
+
if (updatedTicket.branch === null && discoveredBranch !== null) {
|
|
1835
|
+
updatedTicket.branch = discoveredBranch;
|
|
1836
|
+
dirty = true;
|
|
1837
|
+
}
|
|
1838
|
+
if (branchHead !== null && branchHead !== updatedTicket.last_seen_head) {
|
|
1839
|
+
updatedTicket.last_state_change_at = now.toISOString();
|
|
1840
|
+
updatedTicket.last_seen_head = branchHead;
|
|
1841
|
+
lastSeenHead = branchHead;
|
|
1842
|
+
lastStateChangeAt = updatedTicket.last_state_change_at;
|
|
1843
|
+
dirty = true;
|
|
1844
|
+
}
|
|
1845
|
+
// BAPI-963: self-heal the PR number from the PR this tick already observed.
|
|
1846
|
+
// `pr.number` is the validated positive-integer-or-null the PR-state parser
|
|
1847
|
+
// produced, so nothing unvalidated reaches the checkpoint. No PR observed
|
|
1848
|
+
// means no write at all — a null observation must never erase a number a
|
|
1849
|
+
// previous tick durably recorded.
|
|
1850
|
+
if (pr !== null && pr.number !== null && pr.number !== updatedTicket.pr_number) {
|
|
1851
|
+
updatedTicket.pr_number = pr.number;
|
|
1852
|
+
dirty = true;
|
|
1853
|
+
}
|
|
1854
|
+
next.tickets[index] = updatedTicket;
|
|
1855
|
+
if (ci?.ci_last_poll) {
|
|
1856
|
+
next.ci_last_poll = ci.ci_last_poll;
|
|
1857
|
+
dirty = true;
|
|
1858
|
+
}
|
|
1859
|
+
if (leaseEpochChanged) {
|
|
1860
|
+
next.index_scope_lease_epoch = nextLeaseEpoch;
|
|
1861
|
+
dirty = true;
|
|
1862
|
+
}
|
|
1863
|
+
if (dirty) {
|
|
1864
|
+
next.updated_at = now.toISOString();
|
|
1865
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
1866
|
+
skipChmod: deps.platform === "win32",
|
|
1867
|
+
});
|
|
1868
|
+
if (!written.ok) {
|
|
1869
|
+
probeErrors.push({ probe: "checkpoint_write", reason: written.error });
|
|
1870
|
+
}
|
|
1871
|
+
else {
|
|
1872
|
+
// Project what was actually PERSISTED. A failed write leaves the response
|
|
1873
|
+
// reporting the original stored value, so the payload never claims a
|
|
1874
|
+
// durable record that is not on disk.
|
|
1875
|
+
projectedTicket = updatedTicket;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
const payload = {
|
|
1880
|
+
ok: true,
|
|
1881
|
+
epic_key: checkpoint.epic_key,
|
|
1882
|
+
epic_branch: checkpoint.epic_branch,
|
|
1883
|
+
checkpoint_path: checkpointPath,
|
|
1884
|
+
checkpoint_exists: true,
|
|
1885
|
+
all_done: allDone,
|
|
1886
|
+
ticket: projectedTicket === null
|
|
1887
|
+
? null
|
|
1888
|
+
: projectConductEpicTicketFacts(projectedTicket, discoveredBranch),
|
|
1889
|
+
worktree_path: worktreePath,
|
|
1890
|
+
worktree_exists: worktreeExists,
|
|
1891
|
+
branch_head: branchHead,
|
|
1892
|
+
worker_commits_since_spawn: workerCommits,
|
|
1893
|
+
last_seen_head: lastSeenHead,
|
|
1894
|
+
last_state_change_at: lastStateChangeAt,
|
|
1895
|
+
stale_for_seconds: elapsedSeconds(lastStateChangeAt, now),
|
|
1896
|
+
pr,
|
|
1897
|
+
merged_externally: Boolean(mergedExternally),
|
|
1898
|
+
ci: ci?.ci ?? null,
|
|
1899
|
+
review,
|
|
1900
|
+
parse,
|
|
1901
|
+
deadlines: {
|
|
1902
|
+
soft_seconds: checkpoint.deadlines.soft_seconds,
|
|
1903
|
+
hard_seconds: checkpoint.deadlines.hard_seconds,
|
|
1904
|
+
elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
|
|
1905
|
+
},
|
|
1906
|
+
scope,
|
|
1907
|
+
// BAPI-963: `observed` | `absent` | `unavailable`, or null when the question
|
|
1908
|
+
// did not arise (no scope, no access, or a scope that is not `pending`).
|
|
1909
|
+
parse_dispatcher: dispatcher === null ? null : dispatcher.observation,
|
|
1910
|
+
scope_lease_epoch: nextLeaseEpoch,
|
|
1911
|
+
retention_seconds: retentionSeconds,
|
|
1912
|
+
scopes,
|
|
1913
|
+
lock,
|
|
1914
|
+
needs_human: checkpoint.needs_human,
|
|
1915
|
+
probe_errors: probeErrors,
|
|
1916
|
+
};
|
|
1917
|
+
return emitSuccess(deps, options.json, payload, [
|
|
1918
|
+
...renderScopeFreshnessLines(scope, dispatcher),
|
|
1919
|
+
...renderStrandedScopeLines(scopes, declaredScopeId),
|
|
1920
|
+
]);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Render the compact freshness unit for the human-readable `status` output.
|
|
1924
|
+
*
|
|
1925
|
+
* There is no dashboard for an index scope and this deliberately does not invent
|
|
1926
|
+
* one — the unit lives on the status surface that already exists. Its shape is
|
|
1927
|
+
* fixed by what an operator needs to decide, in that order:
|
|
1928
|
+
*
|
|
1929
|
+
* 1. **What is happening to the ticket**, first and in plain language. "Waiting
|
|
1930
|
+
* for index refresh" is the answer to the question actually being asked; a
|
|
1931
|
+
* lifecycle name is not.
|
|
1932
|
+
* 2. **The lifecycle**, then the two commits on SEPARATE, SEPARATELY LABELLED
|
|
1933
|
+
* lines. Printing them together, or printing only one, is what let "the commit
|
|
1934
|
+
* we must index" read as "the commit we indexed".
|
|
1935
|
+
* 3. **The refusal, spelled out**, when there is one. A controlled token is
|
|
1936
|
+
* precise but not self-explaining, so each is given a sentence — and each
|
|
1937
|
+
* sentence distinguishes it from a plain parse failure.
|
|
1938
|
+
*
|
|
1939
|
+
* Returns an empty array when the epic declares no scope, so an epic without one
|
|
1940
|
+
* prints exactly what it printed before.
|
|
1941
|
+
*/
|
|
1942
|
+
/**
|
|
1943
|
+
* Render the STRANDED-SCOPE warning unit for `status` (BAPI-846).
|
|
1944
|
+
*
|
|
1945
|
+
* Deliberately narrow: it names only scopes that are not this epic's and are not
|
|
1946
|
+
* live — the crashed-epic case an operator can act on — and it says nothing at
|
|
1947
|
+
* all when there are none. A full inventory belongs in the JSON payload; the
|
|
1948
|
+
* human output exists to make one specific problem impossible to miss, and a
|
|
1949
|
+
* block that prints on every healthy tick is a block operators stop reading.
|
|
1950
|
+
*
|
|
1951
|
+
* `reclaimed` scopes are omitted: a tombstone is a completed outcome, not
|
|
1952
|
+
* something to act on.
|
|
1953
|
+
*/
|
|
1954
|
+
function renderStrandedScopeLines(scopes, ownScopeId) {
|
|
1955
|
+
const stranded = scopes.filter((scope) => scope.scope_id !== ownScopeId &&
|
|
1956
|
+
scope.lifecycle_state !== "reclaimed" &&
|
|
1957
|
+
!scope.lease_valid);
|
|
1958
|
+
if (stranded.length === 0)
|
|
1959
|
+
return [];
|
|
1960
|
+
const lines = [
|
|
1961
|
+
`${stranded.length} index scope(s) in this repository have no live lease:`,
|
|
1962
|
+
];
|
|
1963
|
+
for (const scope of stranded) {
|
|
1964
|
+
const action = scope.recoverable
|
|
1965
|
+
? "recoverable — `implement-epic recover <EPIC> --scope " + scope.scope_id + "`"
|
|
1966
|
+
: scope.retention_elapsed
|
|
1967
|
+
? "past retention — the sweep will reclaim it"
|
|
1968
|
+
: `retained until ${scope.retention_deadline ?? "an unknown deadline"}`;
|
|
1969
|
+
lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
|
|
1970
|
+
`branch=${scope.feature_branch ?? "unknown"} ${action}`);
|
|
1971
|
+
if (scope.blockers.length > 0) {
|
|
1972
|
+
lines.push(` blocked by: ${scope.blockers.join(", ")}`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
return lines;
|
|
1976
|
+
}
|
|
1977
|
+
function renderScopeFreshnessLines(scope, dispatcher = null) {
|
|
1978
|
+
if (scope === null)
|
|
1979
|
+
return [];
|
|
1980
|
+
// BAPI-963: `pending` gets a sharper headline when nothing is observed sweeping
|
|
1981
|
+
// the parse queue — "Waiting for index refresh" implies autonomous progress
|
|
1982
|
+
// that, with no dispatcher, is never going to happen. Only a POSITIVE `absent`
|
|
1983
|
+
// observation changes the wording; `observed` and `unavailable` both keep the
|
|
1984
|
+
// original line, because an unavailable read is not evidence of absence.
|
|
1985
|
+
const dispatcherAbsent = dispatcher !== null && dispatcher.observation === "absent";
|
|
1986
|
+
const headline = {
|
|
1987
|
+
fresh: "Index is fresh for this epic.",
|
|
1988
|
+
pending: dispatcherAbsent
|
|
1989
|
+
? "Waiting for live parse dispatcher."
|
|
1990
|
+
: "Waiting for index refresh.",
|
|
1991
|
+
blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
|
|
1992
|
+
failed: "Index generation FAILED for this scope.",
|
|
1993
|
+
unavailable: "Index freshness is unavailable — treat as not fresh.",
|
|
1994
|
+
unobserved_advance: "The epic branch has advanced BEYOND what this index scope observed.",
|
|
1995
|
+
};
|
|
1996
|
+
// The action line sits immediately under the headline so the output reads
|
|
1997
|
+
// status -> action -> evidence. `unobserved_advance` is the one state that is
|
|
1998
|
+
// actionable rather than merely informative: it is repairable in place.
|
|
1999
|
+
const action = {
|
|
2000
|
+
unobserved_advance: " Attempting guarded catch-up — replaying the missed merges through the same gate.",
|
|
2001
|
+
};
|
|
2002
|
+
const refusal = {
|
|
2003
|
+
advance_blocked_base_merge: "the base branch was merged forward into the epic branch, which would move the branch's pinned cut point",
|
|
2004
|
+
advance_blocked_unexpected_parent: "the merge commit does not descend directly from the head this scope pinned, so it is not a worker merge",
|
|
2005
|
+
advance_blocked_history_changed: "the pinned head is gone from the branch's history — a force-push or rewrite",
|
|
2006
|
+
advance_blocked_unverifiable: "the advance could not be verified at all, and doubt blocks rather than indexes",
|
|
2007
|
+
};
|
|
2008
|
+
const lines = [
|
|
2009
|
+
headline[scope.freshness_status] ?? "Index freshness is unknown — treat as not fresh.",
|
|
2010
|
+
];
|
|
2011
|
+
const actionLine = action[scope.freshness_status];
|
|
2012
|
+
if (actionLine !== undefined)
|
|
2013
|
+
lines.push(actionLine);
|
|
2014
|
+
if (scope.freshness_status === "pending" && dispatcherAbsent) {
|
|
2015
|
+
lines.push(" No parse dispatcher was observed. The scheduled refresh cannot begin until", " the `worker:` dyno is sweeping the parse queue.");
|
|
2016
|
+
}
|
|
2017
|
+
lines.push(` lifecycle: ${scope.lifecycle_state ?? "unknown"}`, ` Required commit: ${scope.required_commit_sha ?? "none"}`, ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`);
|
|
2018
|
+
if (scope.freshness_status === "unobserved_advance") {
|
|
2019
|
+
lines.push(" Both watermarks agree — at a commit the branch has left behind, which is", " why this is not `fresh`. Run `implement-epic catch-up <EPIC>` to repair it.");
|
|
2020
|
+
}
|
|
2021
|
+
if (scope.blocked_reason !== null) {
|
|
2022
|
+
lines.push(` Reason: ${scope.blocked_reason} — ${refusal[scope.blocked_reason] ?? "the server refused this branch advance"}`);
|
|
2023
|
+
lines.push(" A human must resolve the branch before the epic can continue.");
|
|
2024
|
+
}
|
|
2025
|
+
else if (scope.freshness_status === "failed" && scope.last_error !== null) {
|
|
2026
|
+
lines.push(` Failure category: ${scope.last_error}`);
|
|
2027
|
+
}
|
|
2028
|
+
return lines;
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Project one checkpoint ticket into the published `ticket` facts.
|
|
2032
|
+
*
|
|
2033
|
+
* `branch` comes from the caller's freshly discovered value rather than the
|
|
2034
|
+
* stored one, matching the rest of the status contract. `journal` is copied so
|
|
2035
|
+
* a consumer of the payload can never reach back into checkpoint state — the
|
|
2036
|
+
* status verb's write allowlist is deliberately narrow, and handing out a live
|
|
2037
|
+
* array reference would be a fifth, undeclared write path.
|
|
2038
|
+
*/
|
|
2039
|
+
export function projectConductEpicTicketFacts(ticket, discoveredBranch) {
|
|
2040
|
+
return {
|
|
2041
|
+
key: ticket.key,
|
|
2042
|
+
status: ticket.status,
|
|
2043
|
+
branch: discoveredBranch,
|
|
2044
|
+
pr_number: ticket.pr_number,
|
|
2045
|
+
spawned_at: ticket.spawned_at,
|
|
2046
|
+
parse_requested_at: ticket.parse_requested_at,
|
|
2047
|
+
parse_requested_for_sha: ticket.parse_requested_for_sha,
|
|
2048
|
+
review_verdictless_observations: ticket.review_verdictless_observations,
|
|
2049
|
+
review_verdictless_for_sha: ticket.review_verdictless_for_sha,
|
|
2050
|
+
respawns: ticket.respawns,
|
|
2051
|
+
conflict_attempts: ticket.conflict_attempts,
|
|
2052
|
+
counters: { ...ticket.counters },
|
|
2053
|
+
journal: [...ticket.journal],
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Poll CI for `headSha`, warming the resolve cache at most once.
|
|
2058
|
+
*
|
|
2059
|
+
* `poll-ci-checks` answers `available: false` with a "call resolve-ci-checks
|
|
2060
|
+
* first" action until the repository's check config has been cached. That is an
|
|
2061
|
+
* INSTRUCTION, not an error: resolve once, poll once more, and proceed. Resolve
|
|
2062
|
+
* is also what supplies the required names when the done gate does not.
|
|
2063
|
+
*/
|
|
2064
|
+
async function collectCiFacts(deps, access, headSha, doneGateRequired, previous, probeErrors) {
|
|
2065
|
+
let resolvedValue = null;
|
|
2066
|
+
let resolveCalled = false;
|
|
2067
|
+
const ensureResolved = async () => {
|
|
2068
|
+
if (resolveCalled)
|
|
2069
|
+
return;
|
|
2070
|
+
resolveCalled = true;
|
|
2071
|
+
const resolved = await resolveCiChecks(access, headSha, deps.fetchImpl);
|
|
2072
|
+
if (resolved.ok)
|
|
2073
|
+
resolvedValue = resolved.value;
|
|
2074
|
+
else
|
|
2075
|
+
probeErrors.push({ probe: "ci_resolve", reason: resolved.error });
|
|
2076
|
+
};
|
|
2077
|
+
let polled = await pollCiChecks(access, headSha, deps.fetchImpl);
|
|
2078
|
+
if (!polled.ok) {
|
|
2079
|
+
probeErrors.push({ probe: "ci", reason: polled.error });
|
|
2080
|
+
return null;
|
|
2081
|
+
}
|
|
2082
|
+
if (isRecord(polled.value) && polled.value["available"] === false) {
|
|
2083
|
+
await ensureResolved();
|
|
2084
|
+
polled = await pollCiChecks(access, headSha, deps.fetchImpl);
|
|
2085
|
+
if (!polled.ok) {
|
|
2086
|
+
probeErrors.push({ probe: "ci", reason: polled.error });
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
// Still unavailable after the one resolve-and-re-poll: CI state is UNKNOWN
|
|
2090
|
+
// (BAPI-814/B3). Falling through would build `{complete: false, checks: []}`,
|
|
2091
|
+
// which is indistinguishable from "polled fine, nothing is green yet" — and
|
|
2092
|
+
// would persist a fingerprint derived from an answer that carried no checks,
|
|
2093
|
+
// so the NEXT poll would compare against it and could report
|
|
2094
|
+
// `stable_across_two_polls`. Return before either happens.
|
|
2095
|
+
if (isRecord(polled.value) && polled.value["available"] === false) {
|
|
2096
|
+
probeErrors.push({
|
|
2097
|
+
probe: "ci",
|
|
2098
|
+
reason: "CI checks are unavailable after resolve-ci-checks and a second poll",
|
|
2099
|
+
});
|
|
2100
|
+
return null;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const detail = isRecord(polled.value) ? polled.value["detail"] : null;
|
|
2104
|
+
const rawChecks = isRecord(detail) && Array.isArray(detail["checks"]) ? detail["checks"] : [];
|
|
2105
|
+
const checks = rawChecks.filter(isRecord).map((check) => ({
|
|
2106
|
+
name: typeof check["name"] === "string" ? check["name"] : "",
|
|
2107
|
+
status: check["status"] ?? null,
|
|
2108
|
+
conclusion: check["conclusion"] ?? null,
|
|
2109
|
+
required: check["required"] !== false,
|
|
2110
|
+
}));
|
|
2111
|
+
let required = doneGateRequired;
|
|
2112
|
+
if (required === null) {
|
|
2113
|
+
await ensureResolved();
|
|
2114
|
+
required = requiredCheckNamesFromResolve(resolvedValue);
|
|
2115
|
+
}
|
|
2116
|
+
// Sorted so the fingerprint is order-independent: GitHub returns checks in an
|
|
2117
|
+
// arbitrary order, and an order flip must not read as an unstable poll.
|
|
2118
|
+
const requiredSorted = [...new Set(required)].sort();
|
|
2119
|
+
const complete = requiredSorted.length > 0 &&
|
|
2120
|
+
requiredSorted.every((name) => {
|
|
2121
|
+
const raw = rawChecks.filter(isRecord).find((check) => check["name"] === name);
|
|
2122
|
+
return raw !== undefined && raw["complete"] === true && raw["passed"] === true;
|
|
2123
|
+
});
|
|
2124
|
+
const fingerprint = JSON.stringify(checks
|
|
2125
|
+
.filter((check) => requiredSorted.includes(check.name))
|
|
2126
|
+
.map((check) => [check.name, check.status ?? null, check.conclusion ?? null])
|
|
2127
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
|
2128
|
+
// Stability is a THREE-part identity: same head, same required set, same
|
|
2129
|
+
// per-check conclusions. A head change resets it, which is the point — a green
|
|
2130
|
+
// result observed twice for an old SHA says nothing about the current one.
|
|
2131
|
+
const stable = previous !== null &&
|
|
2132
|
+
previous.head_sha === headSha &&
|
|
2133
|
+
previous.results_fingerprint === fingerprint &&
|
|
2134
|
+
previous.required.length === requiredSorted.length &&
|
|
2135
|
+
previous.required.every((name, i) => name === requiredSorted[i]);
|
|
2136
|
+
return {
|
|
2137
|
+
ci: {
|
|
2138
|
+
required: requiredSorted,
|
|
2139
|
+
complete,
|
|
2140
|
+
stable_across_two_polls: stable,
|
|
2141
|
+
head_sha: headSha,
|
|
2142
|
+
checks,
|
|
2143
|
+
},
|
|
2144
|
+
ci_last_poll: {
|
|
2145
|
+
head_sha: headSha,
|
|
2146
|
+
required: requiredSorted,
|
|
2147
|
+
results_fingerprint: fingerprint,
|
|
2148
|
+
at: deps.now().toISOString(),
|
|
2149
|
+
},
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/** Normalize the review-status envelope into the contract's five fields. */
|
|
2153
|
+
function normalizeReviewStatus(value, optedIn, source) {
|
|
2154
|
+
const available = isRecord(value) && value["available"] === true;
|
|
2155
|
+
const detail = isRecord(value) && isRecord(value["detail"]) ? value["detail"] : null;
|
|
2156
|
+
let verdict = null;
|
|
2157
|
+
if (detail !== null) {
|
|
2158
|
+
const sticky = detail["sticky_verdict"];
|
|
2159
|
+
const native = detail["review_decision"];
|
|
2160
|
+
if (typeof sticky === "string" && sticky.trim().length > 0) {
|
|
2161
|
+
verdict = normalizeVerdict(sticky);
|
|
2162
|
+
}
|
|
2163
|
+
else if (typeof native === "string" && native.trim().length > 0) {
|
|
2164
|
+
verdict = normalizeVerdict(native);
|
|
2165
|
+
}
|
|
2166
|
+
else {
|
|
2167
|
+
verdict = "unknown";
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return {
|
|
2171
|
+
opted_in: optedIn,
|
|
2172
|
+
source,
|
|
2173
|
+
available: isRecord(value) ? Boolean(value["available"]) : null,
|
|
2174
|
+
// An `available: false` envelope still carries whatever verdict the server
|
|
2175
|
+
// could derive; it is passed through rather than nulled, because the caller
|
|
2176
|
+
// reads `available` to decide how much to trust it.
|
|
2177
|
+
verdict: available || verdict !== null ? verdict : null,
|
|
2178
|
+
head_sha: detail !== null && typeof detail["head_sha"] === "string" ? detail["head_sha"] : null,
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
/** Map every known verdict spelling onto the contract's three values. */
|
|
2182
|
+
function normalizeVerdict(raw) {
|
|
2183
|
+
const value = raw.trim().toLowerCase();
|
|
2184
|
+
if (value === "approved")
|
|
2185
|
+
return "approved";
|
|
2186
|
+
if (value === "changes_requested")
|
|
2187
|
+
return "changes_requested";
|
|
2188
|
+
return "unknown";
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Normalize `/jira/parse-status` into the `parse` sub-object.
|
|
2192
|
+
*
|
|
2193
|
+
* The two timestamps are what make the post-merge barrier CAUSAL rather than
|
|
2194
|
+
* coincidental (BAPI-814/A2). `parse.status` is repository-level and stays
|
|
2195
|
+
* `succeeded` from any earlier parse, so "succeeded" alone cannot tell the loop
|
|
2196
|
+
* whether THIS ticket's merge has been indexed; a start or finish strictly later
|
|
2197
|
+
* than the journaled request time can.
|
|
2198
|
+
*
|
|
2199
|
+
* Malformed primitives become `null` rather than being coerced. A blank string
|
|
2200
|
+
* or a number where a timestamp belongs is unavailable evidence, and coercing it
|
|
2201
|
+
* to something truthy would let the barrier pass on a value that means nothing.
|
|
2202
|
+
*/
|
|
2203
|
+
export function normalizeParseStatus(value) {
|
|
2204
|
+
const known = ["idle", "queued", "in_progress", "succeeded", "failed"];
|
|
2205
|
+
const raw = isRecord(value) ? value["status"] : null;
|
|
2206
|
+
const status = typeof raw === "string" && known.includes(raw) ? raw : null;
|
|
2207
|
+
const optionalText = (key) => {
|
|
2208
|
+
const field = isRecord(value) ? value[key] : null;
|
|
2209
|
+
if (typeof field !== "string")
|
|
2210
|
+
return null;
|
|
2211
|
+
const trimmed = field.trim();
|
|
2212
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
2213
|
+
};
|
|
2214
|
+
return {
|
|
2215
|
+
status,
|
|
2216
|
+
terminal: status === "succeeded" || status === "failed",
|
|
2217
|
+
started_at: optionalText("started_at"),
|
|
2218
|
+
finished_at: optionalText("finished_at"),
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
// ---------------------------------------------------------------------------
|
|
2222
|
+
// checkpoint set
|
|
2223
|
+
// ---------------------------------------------------------------------------
|
|
2224
|
+
/**
|
|
2225
|
+
* `implement-epic checkpoint set` — schema-validated ABSOLUTE edits under the lock.
|
|
2226
|
+
*
|
|
2227
|
+
* Absolute, not relative: the caller reads `status`, computes `n + 1`, and writes
|
|
2228
|
+
* the result. A `--increment` flag would need the CLI to re-read between the
|
|
2229
|
+
* caller's read and its write, which is exactly the lost-update race the lock
|
|
2230
|
+
* exists to prevent.
|
|
2231
|
+
*/
|
|
2232
|
+
export async function runConductEpicCheckpointSet(deps, options) {
|
|
2233
|
+
// No credential probe here: `checkpoint set` is entirely local — a file read,
|
|
2234
|
+
// a schema-validated edit, and an atomic write. Resolving Bridge access would
|
|
2235
|
+
// make a purely local operation fail-prone during a credential outage.
|
|
2236
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
2237
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
2238
|
+
// LOCK FIRST, then read (BAPI-814/B5). Reading before acquiring is a
|
|
2239
|
+
// read-before-lock race: the bytes this call validates and rewrites could be
|
|
2240
|
+
// replaced by another process between the read and the acquisition, and the
|
|
2241
|
+
// write would then silently discard that process's update. Everything that
|
|
2242
|
+
// observes or mutates checkpoint state now happens inside one lock lifetime.
|
|
2243
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
2244
|
+
if (!lock.acquired) {
|
|
2245
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
2246
|
+
}
|
|
2247
|
+
try {
|
|
2248
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
2249
|
+
if (read.kind === "missing") {
|
|
2250
|
+
return emitFailure(deps, options.json, [
|
|
2251
|
+
`No checkpoint exists at ${checkpointPath}. Run \`implement-epic init\` first.`,
|
|
2252
|
+
]);
|
|
2253
|
+
}
|
|
2254
|
+
if (read.kind !== "ok")
|
|
2255
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
2256
|
+
const checkpoint = read.checkpoint;
|
|
2257
|
+
const ticketKey = options.ticket;
|
|
2258
|
+
const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
|
|
2259
|
+
if (index === -1) {
|
|
2260
|
+
return emitFailure(deps, options.json, [
|
|
2261
|
+
`${ticketKey} is not one of this epic's tickets (${checkpoint.tickets.map((t) => t.key).join(", ")}).`,
|
|
2262
|
+
]);
|
|
2263
|
+
}
|
|
2264
|
+
const next = {
|
|
2265
|
+
...checkpoint,
|
|
2266
|
+
counters: { ...checkpoint.counters },
|
|
2267
|
+
tickets: checkpoint.tickets.map((entry) => ({
|
|
2268
|
+
...entry,
|
|
2269
|
+
counters: { ...entry.counters },
|
|
2270
|
+
journal: [...entry.journal],
|
|
2271
|
+
})),
|
|
2272
|
+
};
|
|
2273
|
+
const now = deps.now().toISOString();
|
|
2274
|
+
const statusBefore = next.tickets[index].status;
|
|
2275
|
+
// Applied in argv order, so a repeated field's LAST assignment wins — the
|
|
2276
|
+
// same rule a shell applies to repeated options.
|
|
2277
|
+
for (const assignment of options.fields) {
|
|
2278
|
+
const applied = applyFieldAssignment(next, index, assignment);
|
|
2279
|
+
if (applied !== null) {
|
|
2280
|
+
return emitFailure(deps, options.json, [applied]);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
if (next.tickets[index].status !== statusBefore) {
|
|
2284
|
+
next.tickets[index].last_state_change_at = now;
|
|
2285
|
+
}
|
|
2286
|
+
if (options.journal !== undefined) {
|
|
2287
|
+
next.tickets[index] = appendTicketJournal(next.tickets[index], options.journal);
|
|
2288
|
+
}
|
|
2289
|
+
next.updated_at = now;
|
|
2290
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
2291
|
+
skipChmod: deps.platform === "win32",
|
|
2292
|
+
});
|
|
2293
|
+
if (!written.ok)
|
|
2294
|
+
return emitFailure(deps, options.json, [written.error]);
|
|
2295
|
+
// A concise mutation summary: the FIELD NAMES that changed, never the
|
|
2296
|
+
// checkpoint body, which can carry operator-authored `needs_human` evidence.
|
|
2297
|
+
const changed = options.fields.map((f) => f.name);
|
|
2298
|
+
if (options.journal !== undefined)
|
|
2299
|
+
changed.push("journal");
|
|
2300
|
+
return emitSuccess(deps, options.json, {
|
|
2301
|
+
ok: true,
|
|
2302
|
+
epic_key: checkpoint.epic_key,
|
|
2303
|
+
ticket: ticketKey,
|
|
2304
|
+
updated_fields: changed,
|
|
2305
|
+
checkpoint_path: checkpointPath,
|
|
2306
|
+
}, [`Updated ${ticketKey}: ${changed.join(", ")}`]);
|
|
2307
|
+
}
|
|
2308
|
+
finally {
|
|
2309
|
+
await releaseAcquired(lock);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
/** Release a lock we acquired, never one a successor replaced it with. */
|
|
2313
|
+
async function releaseAcquired(lock) {
|
|
2314
|
+
await lock.release();
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Apply one `--field name value` assignment in place, returning an error message
|
|
2318
|
+
* or `null`.
|
|
2319
|
+
*
|
|
2320
|
+
* Every value is parsed by TYPE, not coerced: `"null"` is only null where null is
|
|
2321
|
+
* a legal value, a counter must be a non-negative integer, a branch must survive
|
|
2322
|
+
* `validateBranchName`, and a status must be in the closed vocabulary. A
|
|
2323
|
+
* permissive parser here would let a typo silently write a nonsense state that
|
|
2324
|
+
* the loop then acts on.
|
|
2325
|
+
*/
|
|
2326
|
+
function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
|
|
2327
|
+
const { name, value } = assignment;
|
|
2328
|
+
const ticket = checkpoint.tickets[ticketIndex];
|
|
2329
|
+
switch (name) {
|
|
2330
|
+
case "status": {
|
|
2331
|
+
if (!CONDUCT_EPIC_TICKET_STATUSES.includes(value)) {
|
|
2332
|
+
return `Invalid status '${value}'. Expected one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`;
|
|
2333
|
+
}
|
|
2334
|
+
ticket.status = value;
|
|
2335
|
+
return null;
|
|
2336
|
+
}
|
|
2337
|
+
case "branch": {
|
|
2338
|
+
if (value === "null") {
|
|
2339
|
+
ticket.branch = null;
|
|
2340
|
+
return null;
|
|
2341
|
+
}
|
|
2342
|
+
const reason = validateBranchName(value);
|
|
2343
|
+
if (reason)
|
|
2344
|
+
return `Invalid branch: ${reason}`;
|
|
2345
|
+
ticket.branch = value;
|
|
2346
|
+
return null;
|
|
2347
|
+
}
|
|
2348
|
+
case "pr_number": {
|
|
2349
|
+
if (value === "null") {
|
|
2350
|
+
ticket.pr_number = null;
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
const parsed = parseIntegerField(value);
|
|
2354
|
+
if (parsed === null || parsed <= 0)
|
|
2355
|
+
return "pr_number must be a positive integer or null.";
|
|
2356
|
+
ticket.pr_number = parsed;
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
case "spawned_at": {
|
|
2360
|
+
if (value === "null") {
|
|
2361
|
+
ticket.spawned_at = null;
|
|
2362
|
+
return null;
|
|
2363
|
+
}
|
|
2364
|
+
if (value.trim().length === 0)
|
|
2365
|
+
return "spawned_at must be a non-empty timestamp or null.";
|
|
2366
|
+
ticket.spawned_at = value;
|
|
2367
|
+
return null;
|
|
2368
|
+
}
|
|
2369
|
+
case "parse_requested_at":
|
|
2370
|
+
case "parse_requested_for_sha":
|
|
2371
|
+
// BAPI-915: the head the verdictless counter is bound to. Reuses this
|
|
2372
|
+
// nullable-string branch so a tick can clear the binding as deliberately as
|
|
2373
|
+
// it sets it — the counter resets by writing `null` here, not by hand-editing.
|
|
2374
|
+
case "review_verdictless_for_sha": {
|
|
2375
|
+
// The durable half of the post-merge causal barrier (BAPI-825/A2). Both
|
|
2376
|
+
// follow the module's nullable-string convention so a tick can clear them
|
|
2377
|
+
// as deliberately as it sets them — an operator re-running a parse writes
|
|
2378
|
+
// `null` here rather than hand-editing the checkpoint.
|
|
2379
|
+
if (value === "null") {
|
|
2380
|
+
ticket[name] = null;
|
|
2381
|
+
return null;
|
|
2382
|
+
}
|
|
2383
|
+
if (value.trim().length === 0) {
|
|
2384
|
+
return `${name} must be a non-empty string or null.`;
|
|
2385
|
+
}
|
|
2386
|
+
ticket[name] = value;
|
|
2387
|
+
return null;
|
|
2388
|
+
}
|
|
2389
|
+
case "respawns":
|
|
2390
|
+
case "conflict_attempts":
|
|
2391
|
+
// BAPI-915: the head-bound verdictless observation count. Reuses this
|
|
2392
|
+
// non-negative-integer branch, so a negative or non-integer value is
|
|
2393
|
+
// refused by the same parser that guards every other per-ticket counter.
|
|
2394
|
+
case "review_verdictless_observations": {
|
|
2395
|
+
const parsed = parseIntegerField(value);
|
|
2396
|
+
if (parsed === null)
|
|
2397
|
+
return `${name} must be a non-negative integer.`;
|
|
2398
|
+
ticket[name] = parsed;
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
case "counters.sessions_spawned":
|
|
2402
|
+
case "counters.plan_generations_observed":
|
|
2403
|
+
case "counters.merge_attempts": {
|
|
2404
|
+
const parsed = parseIntegerField(value);
|
|
2405
|
+
if (parsed === null)
|
|
2406
|
+
return `${name} must be a non-negative integer.`;
|
|
2407
|
+
const key = name.slice("counters.".length);
|
|
2408
|
+
ticket.counters[key] = parsed;
|
|
2409
|
+
return null;
|
|
2410
|
+
}
|
|
2411
|
+
case "counters.iterations":
|
|
2412
|
+
case "counters.merges": {
|
|
2413
|
+
const parsed = parseIntegerField(value);
|
|
2414
|
+
if (parsed === null)
|
|
2415
|
+
return `${name} must be a non-negative integer.`;
|
|
2416
|
+
checkpoint.counters[name.slice("counters.".length)] = parsed;
|
|
2417
|
+
return null;
|
|
2418
|
+
}
|
|
2419
|
+
case "needs_human": {
|
|
2420
|
+
if (value === "null") {
|
|
2421
|
+
checkpoint.needs_human = null;
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
let parsed;
|
|
2425
|
+
try {
|
|
2426
|
+
parsed = JSON.parse(value);
|
|
2427
|
+
}
|
|
2428
|
+
catch {
|
|
2429
|
+
return "needs_human must be JSON null or an object with reason, evidence, and at.";
|
|
2430
|
+
}
|
|
2431
|
+
if (parsed === null) {
|
|
2432
|
+
checkpoint.needs_human = null;
|
|
2433
|
+
return null;
|
|
2434
|
+
}
|
|
2435
|
+
if (!isRecord(parsed) ||
|
|
2436
|
+
typeof parsed["reason"] !== "string" ||
|
|
2437
|
+
typeof parsed["evidence"] !== "string" ||
|
|
2438
|
+
typeof parsed["at"] !== "string") {
|
|
2439
|
+
return "needs_human must be JSON null or an object with string reason, evidence, and at.";
|
|
2440
|
+
}
|
|
2441
|
+
checkpoint.needs_human = {
|
|
2442
|
+
reason: parsed["reason"],
|
|
2443
|
+
evidence: parsed["evidence"],
|
|
2444
|
+
at: parsed["at"],
|
|
2445
|
+
};
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
default:
|
|
2449
|
+
return (`Unknown field '${name}'. Ticket fields: ${TICKET_FIELDS.join(", ")}. ` +
|
|
2450
|
+
`Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
/** Parse a non-negative integer, rejecting `"1.5"`, `"1e3"`, `"0x1"`, and `""`. */
|
|
2454
|
+
function parseIntegerField(value) {
|
|
2455
|
+
if (!/^\d+$/.test(value.trim()))
|
|
2456
|
+
return null;
|
|
2457
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
2458
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
2459
|
+
}
|
|
2460
|
+
// ---------------------------------------------------------------------------
|
|
2461
|
+
// spawn
|
|
2462
|
+
// ---------------------------------------------------------------------------
|
|
2463
|
+
/**
|
|
2464
|
+
* `implement-epic spawn` — open exactly ONE agent tab for a ticket.
|
|
2465
|
+
*
|
|
2466
|
+
* Respawn and conflict budgets are deliberately NOT enforced here. The caller
|
|
2467
|
+
* owns them: it reads `status`, decides whether another attempt is warranted,
|
|
2468
|
+
* and only then calls `spawn`. Enforcing a budget in both places means two
|
|
2469
|
+
* thresholds that drift, and the one in prose wins by accident.
|
|
2470
|
+
*/
|
|
2471
|
+
export async function runConductEpicSpawn(deps, options) {
|
|
2472
|
+
// Local-only, like `checkpoint set`: no credential probe. The spawned agent
|
|
2473
|
+
// resolves its own credentials.
|
|
2474
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
2475
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
2476
|
+
// LOCK FIRST (BAPI-814/B5). Checkpoint read, ticket validation, worktree
|
|
2477
|
+
// resolution, prompt read, the spawn itself, and the counter write all live
|
|
2478
|
+
// inside one lock lifetime — so no other process can advance the counters
|
|
2479
|
+
// between the read this call validates and the write it performs.
|
|
2480
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
2481
|
+
if (!lock.acquired) {
|
|
2482
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
2483
|
+
}
|
|
2484
|
+
try {
|
|
2485
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
2486
|
+
if (read.kind === "missing") {
|
|
2487
|
+
return emitFailure(deps, options.json, [
|
|
2488
|
+
`No checkpoint exists at ${checkpointPath}. Run \`implement-epic init\` first.`,
|
|
2489
|
+
]);
|
|
2490
|
+
}
|
|
2491
|
+
if (read.kind !== "ok")
|
|
2492
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
2493
|
+
const checkpoint = read.checkpoint;
|
|
2494
|
+
const ticketKey = options.ticket;
|
|
2495
|
+
const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
|
|
2496
|
+
if (index === -1) {
|
|
2497
|
+
return emitFailure(deps, options.json, [`${ticketKey} is not one of this epic's tickets.`]);
|
|
2498
|
+
}
|
|
2499
|
+
// Resolve the worktree BEFORE reading the prompt and spawning: a tab opened
|
|
2500
|
+
// in the wrong directory does real work in the wrong place.
|
|
2501
|
+
const listed = await git(deps, ["worktree", "list", "--porcelain"]);
|
|
2502
|
+
if (listed.exitCode !== 0) {
|
|
2503
|
+
return emitFailure(deps, options.json, ["git worktree list failed; the ticket worktree could not be resolved."]);
|
|
2504
|
+
}
|
|
2505
|
+
const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticketKey, checkpoint.tickets[index].branch);
|
|
2506
|
+
if (found === null) {
|
|
2507
|
+
return emitFailure(deps, options.json, [
|
|
2508
|
+
`No worktree was found for ${ticketKey}. Expected a worktree on the ticket's branch, ` +
|
|
2509
|
+
`feature/${ticketKey}, or feature/${ticketKey}-<slug>.`,
|
|
2510
|
+
]);
|
|
2511
|
+
}
|
|
2512
|
+
try {
|
|
2513
|
+
await deps.fs.stat(found.path);
|
|
2514
|
+
}
|
|
2515
|
+
catch {
|
|
2516
|
+
return emitFailure(deps, options.json, [`The worktree path for ${ticketKey} is not accessible.`]);
|
|
2517
|
+
}
|
|
2518
|
+
let prompt;
|
|
2519
|
+
try {
|
|
2520
|
+
prompt = await deps.fs.readFile(options.promptFile);
|
|
2521
|
+
}
|
|
2522
|
+
catch {
|
|
2523
|
+
return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
|
|
2524
|
+
}
|
|
2525
|
+
// BAPI-844: the scope this epic was cut against, read ONLY from the durable
|
|
2526
|
+
// server-returned value on the checkpoint. `process.env.BAPI_INDEX_SCOPE` is
|
|
2527
|
+
// deliberately not consulted — an operator's shell is not the epic's routing
|
|
2528
|
+
// decision, and a pilot ticket has no server-side membership to fall back on,
|
|
2529
|
+
// so an ambient value would silently route this worker's research somewhere
|
|
2530
|
+
// nobody chose. A malformed recorded value stops the spawn with the fixed
|
|
2531
|
+
// configuration error rather than launching an unscoped (canonical) worker.
|
|
2532
|
+
let indexScope;
|
|
2533
|
+
try {
|
|
2534
|
+
indexScope = validateOptionalIndexScope(checkpoint.index_scope_id);
|
|
2535
|
+
}
|
|
2536
|
+
catch {
|
|
2537
|
+
return emitFailure(deps, options.json, [INDEX_SCOPE_CONFIGURATION_ERROR]);
|
|
2538
|
+
}
|
|
2539
|
+
const spawned = await spawnConductEpicAgentTab({
|
|
2540
|
+
ticketKey,
|
|
2541
|
+
worktreePath: found.path,
|
|
2542
|
+
prompt,
|
|
2543
|
+
agent: options.agent,
|
|
2544
|
+
platform: deps.platform,
|
|
2545
|
+
...(indexScope === undefined ? {} : { indexScope }),
|
|
2546
|
+
}, deps.spawnTab);
|
|
2547
|
+
if (!spawned.ok)
|
|
2548
|
+
return emitFailure(deps, options.json, [spawned.error]);
|
|
2549
|
+
// Counters advance only AFTER the tab actually opened. Incrementing first
|
|
2550
|
+
// would let a failed spawn consume the caller's respawn budget.
|
|
2551
|
+
const now = deps.now().toISOString();
|
|
2552
|
+
const next = {
|
|
2553
|
+
...checkpoint,
|
|
2554
|
+
counters: { ...checkpoint.counters },
|
|
2555
|
+
tickets: checkpoint.tickets.map((entry) => ({
|
|
2556
|
+
...entry,
|
|
2557
|
+
counters: { ...entry.counters },
|
|
2558
|
+
journal: [...entry.journal],
|
|
2559
|
+
})),
|
|
2560
|
+
};
|
|
2561
|
+
next.tickets[index].branch = found.branch;
|
|
2562
|
+
next.tickets[index].counters.sessions_spawned += 1;
|
|
2563
|
+
next.tickets[index] = appendTicketJournal(next.tickets[index], `${now} spawned ${options.agent ?? "claude"} in ${found.branch}`);
|
|
2564
|
+
next.updated_at = now;
|
|
2565
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
2566
|
+
skipChmod: deps.platform === "win32",
|
|
2567
|
+
});
|
|
2568
|
+
if (!written.ok)
|
|
2569
|
+
return emitFailure(deps, options.json, [written.error]);
|
|
2570
|
+
return emitSuccess(deps, options.json, {
|
|
2571
|
+
ok: true,
|
|
2572
|
+
epic_key: checkpoint.epic_key,
|
|
2573
|
+
ticket: ticketKey,
|
|
2574
|
+
branch: found.branch,
|
|
2575
|
+
worktree_path: found.path,
|
|
2576
|
+
sessions_spawned: next.tickets[index].counters.sessions_spawned,
|
|
2577
|
+
}, [`Spawned one agent tab for ${ticketKey} in ${found.path}`]);
|
|
2578
|
+
}
|
|
2579
|
+
finally {
|
|
2580
|
+
await releaseAcquired(lock);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
// ---------------------------------------------------------------------------
|
|
2584
|
+
// finish
|
|
2585
|
+
// ---------------------------------------------------------------------------
|
|
2586
|
+
/**
|
|
2587
|
+
* `implement-epic finish` — retire the epic's index scope and wind down.
|
|
2588
|
+
*
|
|
2589
|
+
* BAPI-847: there is nothing to RESTORE. `init` no longer repoints the
|
|
2590
|
+
* repository's `base_branch`, so `finish` has no repository-wide configuration
|
|
2591
|
+
* to put back — it retires the scope the epic was cut against and reports that
|
|
2592
|
+
* scope's lifecycle outcome and nothing else. The checkpoint's
|
|
2593
|
+
* `base_branch_original` remains display-only: it records which base the epic was
|
|
2594
|
+
* cut from, never a value this verb writes anywhere.
|
|
2595
|
+
*/
|
|
2596
|
+
export async function runConductEpicFinish(deps, options) {
|
|
2597
|
+
const accessProbe = await resolveAccess(deps);
|
|
2598
|
+
if (!accessProbe.ok)
|
|
2599
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2600
|
+
const access = accessProbe.access;
|
|
2601
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2602
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
2603
|
+
if (read.kind === "missing") {
|
|
2604
|
+
return emitFailure(deps, options.json, [`No checkpoint exists at ${checkpointPath}.`]);
|
|
2605
|
+
}
|
|
2606
|
+
if (read.kind !== "ok")
|
|
2607
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
2608
|
+
const checkpoint = read.checkpoint;
|
|
2609
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
2610
|
+
if (!lock.acquired) {
|
|
2611
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
2612
|
+
}
|
|
2613
|
+
// BAPI-846: `finish` RETIRES the epic's index scope; it never deletes it. The
|
|
2614
|
+
// scope keeps every Postgres row and every Pinecone namespace and stays
|
|
2615
|
+
// readable for post-mortem for the whole retention window — deletion is always
|
|
2616
|
+
// the scheduled sweep's or an explicit `reclaim`'s.
|
|
2617
|
+
//
|
|
2618
|
+
// Retirement runs INSIDE the lock, before it is released, so a concurrent
|
|
2619
|
+
// `finish` cannot interleave with it. It calls the retire API and never a
|
|
2620
|
+
// namespace or database deletion, and enough local state is retained (the
|
|
2621
|
+
// checkpoint keeps `index_scope_id` and the epoch) that a failed retirement can
|
|
2622
|
+
// simply be retried with `implement-epic retire`.
|
|
2623
|
+
let scopeRetired = null;
|
|
2624
|
+
let scopeRetirementError = null;
|
|
2625
|
+
if (typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0) {
|
|
2626
|
+
const retirement = await retireScopeWithEpoch(deps, options, access, {
|
|
2627
|
+
scopeId: checkpoint.index_scope_id,
|
|
2628
|
+
checkpoint,
|
|
2629
|
+
leaseEpoch: checkpoint.index_scope_lease_epoch,
|
|
2630
|
+
});
|
|
2631
|
+
scopeRetired = retirement.ok;
|
|
2632
|
+
if (!retirement.ok)
|
|
2633
|
+
scopeRetirementError = retirement.reason;
|
|
2634
|
+
}
|
|
2635
|
+
await releaseAcquired(lock);
|
|
2636
|
+
// A failed retirement does NOT fail `finish`: the lock is already released and
|
|
2637
|
+
// every other wind-down step has happened, so reporting failure would invite a
|
|
2638
|
+
// re-run that redoes work already done. It is surfaced instead, with the one
|
|
2639
|
+
// command that fixes it — and the scope enters retention on lease expiry
|
|
2640
|
+
// regardless.
|
|
2641
|
+
if (scopeRetirementError !== null) {
|
|
2642
|
+
deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
|
|
2643
|
+
`Retry with \`implement-epic retire ${checkpoint.epic_key}\`.`);
|
|
2644
|
+
}
|
|
2645
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
2646
|
+
// Wind-down: ensure the PR exists AND request it be marked ready for human
|
|
2647
|
+
// review. Fail-open, alongside scope retirement — a failure here never rolls
|
|
2648
|
+
// back retirement or fails `finish`, which has already released the lock.
|
|
2649
|
+
const integrationPrAdvisory = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
2650
|
+
epicKey: checkpoint.epic_key,
|
|
2651
|
+
epicBranch: checkpoint.epic_branch,
|
|
2652
|
+
baseBranch: checkpoint.base_branch_original,
|
|
2653
|
+
command: "implement-epic finish",
|
|
2654
|
+
requestReady: true,
|
|
2655
|
+
});
|
|
2656
|
+
deps.errorLog(`Integration PR: ${JSON.stringify(integrationPrAdvisory)}`);
|
|
2657
|
+
const summary = {
|
|
2658
|
+
ok: true,
|
|
2659
|
+
epic_key: checkpoint.epic_key,
|
|
2660
|
+
epic_branch: checkpoint.epic_branch,
|
|
2661
|
+
/** `null` when the epic declares no scope; `false` when retirement failed. */
|
|
2662
|
+
scope_retired: scopeRetired,
|
|
2663
|
+
counters: { ...checkpoint.counters },
|
|
2664
|
+
needs_human: checkpoint.needs_human,
|
|
2665
|
+
tickets: checkpoint.tickets.map((ticket) => ({
|
|
2666
|
+
key: ticket.key,
|
|
2667
|
+
status: ticket.status,
|
|
2668
|
+
pr_number: ticket.pr_number,
|
|
2669
|
+
counters: { ...ticket.counters },
|
|
2670
|
+
})),
|
|
2671
|
+
};
|
|
2672
|
+
const humanLines = [
|
|
2673
|
+
`Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
|
|
2674
|
+
`index scope: ${scopeRetired === null
|
|
2675
|
+
? "none declared"
|
|
2676
|
+
: scopeRetired
|
|
2677
|
+
? "retired (retention clock started; nothing deleted)"
|
|
2678
|
+
: "NOT retired — see the error above"}`,
|
|
2679
|
+
`iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
|
|
2680
|
+
...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
|
|
2681
|
+
`spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
|
|
2682
|
+
`merges ${ticket.counters.merge_attempts}`),
|
|
2683
|
+
`needs_human: ${checkpoint.needs_human === null ? "none" : checkpoint.needs_human.reason}`,
|
|
2684
|
+
];
|
|
2685
|
+
return emitSuccess(deps, options.json, summary, humanLines);
|
|
2686
|
+
}
|
|
2687
|
+
// ---------------------------------------------------------------------------
|
|
2688
|
+
// Index-scope lifecycle verbs (BAPI-846)
|
|
2689
|
+
// ---------------------------------------------------------------------------
|
|
2690
|
+
//
|
|
2691
|
+
// `recover`, `retire`, and `reclaim` all reach the authenticated Bridge API and
|
|
2692
|
+
// nothing else. There is deliberately no local Pinecone client, no SQL, and no
|
|
2693
|
+
// deletion path in this process: the server owns every destructive decision, and
|
|
2694
|
+
// a CLI that could delete directly would be a second authority with none of the
|
|
2695
|
+
// server's fencing, locking, or blocker checks.
|
|
2696
|
+
//
|
|
2697
|
+
// Each one persists the server's returned fencing epoch into the protected
|
|
2698
|
+
// checkpoint — never into argv or stdout — so a later heartbeat or retirement
|
|
2699
|
+
// uses the generation the server actually minted.
|
|
2700
|
+
/**
|
|
2701
|
+
* Resolve which scope a lifecycle verb targets.
|
|
2702
|
+
*
|
|
2703
|
+
* `--scope` wins when supplied; otherwise the epic's own scope is read from the
|
|
2704
|
+
* checkpoint. The explicit flag exists for the case the discovery surface is FOR:
|
|
2705
|
+
* a crashed epic whose local checkpoint is gone or was never written, whose scope
|
|
2706
|
+
* an operator found on `status`.
|
|
2707
|
+
*/
|
|
2708
|
+
async function resolveLifecycleScope(deps, options, checkpointPath) {
|
|
2709
|
+
if (options.scope !== undefined) {
|
|
2710
|
+
// An explicitly named scope carries no local lease state, so any epoch must
|
|
2711
|
+
// come from the server. Reading one from an unrelated checkpoint would send a
|
|
2712
|
+
// generation that belongs to a different scope.
|
|
2713
|
+
return { ok: true, scopeId: options.scope, checkpoint: null, leaseEpoch: null };
|
|
2714
|
+
}
|
|
2715
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
2716
|
+
if (read.kind === "missing") {
|
|
2717
|
+
return {
|
|
2718
|
+
ok: false,
|
|
2719
|
+
reason: `No checkpoint exists at ${checkpointPath}. ` +
|
|
2720
|
+
"Pass --scope <id> to target a scope directly (see `implement-epic status`).",
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
if (read.kind !== "ok")
|
|
2724
|
+
return { ok: false, reason: read.error };
|
|
2725
|
+
const scopeId = read.checkpoint.index_scope_id;
|
|
2726
|
+
if (typeof scopeId !== "string" || scopeId.length === 0) {
|
|
2727
|
+
return { ok: false, reason: `${options.epicKey} declares no index scope.` };
|
|
2728
|
+
}
|
|
2729
|
+
return {
|
|
2730
|
+
ok: true,
|
|
2731
|
+
scopeId,
|
|
2732
|
+
checkpoint: read.checkpoint,
|
|
2733
|
+
leaseEpoch: read.checkpoint.index_scope_lease_epoch,
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Persist the server's authoritative fencing epoch into the checkpoint.
|
|
2738
|
+
*
|
|
2739
|
+
* Best-effort by design: the lifecycle call already succeeded on the server, and
|
|
2740
|
+
* failing the command because a local cache write failed would report a
|
|
2741
|
+
* successful recovery as a failure. The next `status` re-reads the epoch anyway.
|
|
2742
|
+
*/
|
|
2743
|
+
async function persistScopeLeaseEpoch(deps, checkpointPath, checkpoint, leaseEpoch) {
|
|
2744
|
+
if (checkpoint === null || leaseEpoch === null)
|
|
2745
|
+
return;
|
|
2746
|
+
if (checkpoint.index_scope_lease_epoch === leaseEpoch)
|
|
2747
|
+
return;
|
|
2748
|
+
const next = {
|
|
2749
|
+
...checkpoint,
|
|
2750
|
+
index_scope_lease_epoch: leaseEpoch,
|
|
2751
|
+
updated_at: deps.now().toISOString(),
|
|
2752
|
+
};
|
|
2753
|
+
await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
2754
|
+
skipChmod: deps.platform === "win32",
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
/** `implement-epic recover` — take a new ownership generation for a scope. */
|
|
2758
|
+
export async function runConductEpicRecover(deps, options) {
|
|
2759
|
+
const accessProbe = await resolveAccess(deps);
|
|
2760
|
+
if (!accessProbe.ok)
|
|
2761
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2762
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2763
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2764
|
+
if (!target.ok)
|
|
2765
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2766
|
+
const recovered = await recoverIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
|
|
2767
|
+
if (!recovered.ok) {
|
|
2768
|
+
return emitFailure(deps, options.json, [
|
|
2769
|
+
`The index scope could not be recovered: ${recovered.error}`,
|
|
2770
|
+
]);
|
|
2771
|
+
}
|
|
2772
|
+
await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, recovered.value.lease_epoch);
|
|
2773
|
+
return emitSuccess(deps, options.json, {
|
|
2774
|
+
ok: true,
|
|
2775
|
+
epic_key: options.epicKey,
|
|
2776
|
+
scope_id: recovered.value.scope_id,
|
|
2777
|
+
lifecycle_state: recovered.value.lifecycle_state,
|
|
2778
|
+
lease_epoch: recovered.value.lease_epoch,
|
|
2779
|
+
lease_expires_at: recovered.value.lease_expires_at,
|
|
2780
|
+
}, [
|
|
2781
|
+
`Recovered index scope ${recovered.value.scope_id}.`,
|
|
2782
|
+
` lifecycle: ${recovered.value.lifecycle_state ?? "unknown"}`,
|
|
2783
|
+
` lease epoch: ${recovered.value.lease_epoch ?? "unknown"} (previous owners are now fenced)`,
|
|
2784
|
+
` lease expires: ${recovered.value.lease_expires_at ?? "unknown"}`,
|
|
2785
|
+
]);
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* `implement-epic catch-up` — replay a scope's MISSED merge observations.
|
|
2789
|
+
*
|
|
2790
|
+
* The verb that rescues an ALREADY-stranded epic. Row 5 of `implement-epic` calls
|
|
2791
|
+
* the same endpoint automatically when `status` reports `unobserved_advance`, so
|
|
2792
|
+
* the common case self-heals on a tick; this verb exists for the epic that is
|
|
2793
|
+
* stranded right now and should not have to wait for one.
|
|
2794
|
+
*
|
|
2795
|
+
* It is a guarded catch-up, and the wording here is deliberate everywhere it
|
|
2796
|
+
* appears: it replays verified merge evidence through the server's own AC-10
|
|
2797
|
+
* gate. It cannot force anything, and no flag will ever let it.
|
|
2798
|
+
*/
|
|
2799
|
+
export async function runConductEpicCatchUp(deps, options) {
|
|
2800
|
+
const accessProbe = await resolveAccess(deps);
|
|
2801
|
+
if (!accessProbe.ok)
|
|
2802
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2803
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2804
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2805
|
+
if (!target.ok)
|
|
2806
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2807
|
+
const result = await catchUpIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
|
|
2808
|
+
if (!result.ok) {
|
|
2809
|
+
return emitFailure(deps, options.json, [
|
|
2810
|
+
`The guarded catch-up could not run: ${result.error}`,
|
|
2811
|
+
]);
|
|
2812
|
+
}
|
|
2813
|
+
const value = result.value;
|
|
2814
|
+
const payload = {
|
|
2815
|
+
ok: value.ok,
|
|
2816
|
+
epic_key: options.epicKey,
|
|
2817
|
+
scope_id: target.scopeId,
|
|
2818
|
+
outcome: value.outcome,
|
|
2819
|
+
reason: value.reason,
|
|
2820
|
+
required_commit_sha: value.required_commit_sha,
|
|
2821
|
+
parse_scheduled: value.parse_scheduled,
|
|
2822
|
+
};
|
|
2823
|
+
// BAPI-963. Catch-up says "a re-parse is scheduled", but NOTHING in the pilot
|
|
2824
|
+
// dispatches it: the queue is swept by the APScheduler job in `worker.py`'s job
|
|
2825
|
+
// set, and the web dyno runs with `DISABLE_SCHEDULER=true`. During the BAPI-943
|
|
2826
|
+
// pilot the first catch-up worked only because an unrelated v2 run's server
|
|
2827
|
+
// happened to be sweeping; had that process stayed dead, the scope would have
|
|
2828
|
+
// sat at `pending` forever with no diagnostic. Read the dispatcher only when a
|
|
2829
|
+
// parse was actually scheduled, so an ordinary catch-up adds no request.
|
|
2830
|
+
let dispatcher = null;
|
|
2831
|
+
if (value.ok && value.parse_scheduled) {
|
|
2832
|
+
const health = await getParseDispatcherHealth(accessProbe.access, deps.fetchImpl);
|
|
2833
|
+
// Advisory: a failed read leaves the successful catch-up successful and simply
|
|
2834
|
+
// says nothing, rather than claiming a dispatcher is missing.
|
|
2835
|
+
if (health.ok)
|
|
2836
|
+
dispatcher = health.value;
|
|
2837
|
+
}
|
|
2838
|
+
const lines = renderCatchUpLines(value, target.scopeId, dispatcher);
|
|
2839
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
2840
|
+
// Retry point for deferred provisioning: only after a SUCCESSFUL reconciliation
|
|
2841
|
+
// (`value.ok`), and only when the checkpoint carries the epic/base branches this
|
|
2842
|
+
// needs (absent for an explicit `--scope` target with no local checkpoint).
|
|
2843
|
+
if (value.ok && target.checkpoint !== null) {
|
|
2844
|
+
const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
2845
|
+
epicKey: options.epicKey,
|
|
2846
|
+
epicBranch: target.checkpoint.epic_branch,
|
|
2847
|
+
baseBranch: target.checkpoint.base_branch_original,
|
|
2848
|
+
command: "implement-epic catch-up",
|
|
2849
|
+
});
|
|
2850
|
+
lines.push(`integration pr: ${JSON.stringify(formatted)}`);
|
|
2851
|
+
}
|
|
2852
|
+
return value.ok
|
|
2853
|
+
? emitSuccess(deps, options.json, payload, lines)
|
|
2854
|
+
: emitFailure(deps, options.json, lines, payload);
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Render one guarded catch-up outcome as distinct, bounded operator lines.
|
|
2858
|
+
*
|
|
2859
|
+
* Every outcome gets its own sentence rather than collapsing into "it did not
|
|
2860
|
+
* work": the three successes call for different next steps (nothing, poll, poll),
|
|
2861
|
+
* and the refusals call for genuinely different human action — a controlled AC-10
|
|
2862
|
+
* block needs the BRANCH fixed, a history-limit refusal needs a fresh scope, and
|
|
2863
|
+
* an `unavailable` needs the provider to come back.
|
|
2864
|
+
*/
|
|
2865
|
+
function renderCatchUpLines(value, scopeId, dispatcher = null) {
|
|
2866
|
+
const pin = ` Required commit: ${value.required_commit_sha ?? "unknown"}`;
|
|
2867
|
+
// BAPI-963: appended only on POSITIVE evidence that nothing is sweeping. An
|
|
2868
|
+
// `unavailable` observation says nothing, because an advisory that cries wolf
|
|
2869
|
+
// on a network blip is one an operator learns to skip.
|
|
2870
|
+
const waiting = dispatcher !== null && dispatcher.observation === "absent"
|
|
2871
|
+
? [
|
|
2872
|
+
" WAITING ON: a live parse dispatcher. No dispatcher was observed, so the",
|
|
2873
|
+
" scheduled re-parse cannot begin until the `worker:` dyno is sweeping the parse queue.",
|
|
2874
|
+
]
|
|
2875
|
+
: [];
|
|
2876
|
+
switch (value.outcome) {
|
|
2877
|
+
case "repaired":
|
|
2878
|
+
return [
|
|
2879
|
+
`Guarded catch-up repaired index scope ${scopeId}.`,
|
|
2880
|
+
" Every replayed merge passed the AC-10 gate.",
|
|
2881
|
+
pin,
|
|
2882
|
+
value.parse_scheduled
|
|
2883
|
+
? " A re-parse is scheduled. Poll `implement-epic status` until freshness reads `fresh`."
|
|
2884
|
+
: " No re-parse was scheduled; poll `implement-epic status` for the scope's own state.",
|
|
2885
|
+
...waiting,
|
|
2886
|
+
];
|
|
2887
|
+
case "already_current":
|
|
2888
|
+
return [
|
|
2889
|
+
`Index scope ${scopeId} is already current. Nothing was written.`,
|
|
2890
|
+
pin,
|
|
2891
|
+
];
|
|
2892
|
+
case "parse_scheduled":
|
|
2893
|
+
return [
|
|
2894
|
+
`Index scope ${scopeId} was already pinned at its branch head; no advance was needed.`,
|
|
2895
|
+
value.parse_scheduled
|
|
2896
|
+
? " A re-parse is scheduled. Poll `implement-epic status` until freshness reads `fresh`."
|
|
2897
|
+
: ` No re-parse was scheduled (${value.reason ?? "the scope is not schedulable right now"}).`,
|
|
2898
|
+
pin,
|
|
2899
|
+
...waiting,
|
|
2900
|
+
];
|
|
2901
|
+
case "race_lost":
|
|
2902
|
+
return [
|
|
2903
|
+
`Another observation already owns this scope's target; the catch-up did nothing.`,
|
|
2904
|
+
" This is the safe outcome of a race, not a failure. Poll `implement-epic status`.",
|
|
2905
|
+
pin,
|
|
2906
|
+
];
|
|
2907
|
+
case "blocked":
|
|
2908
|
+
return [
|
|
2909
|
+
`Guarded catch-up REFUSED to advance index scope ${scopeId}.`,
|
|
2910
|
+
` Reason: ${value.reason ?? "the server refused this branch advance"}`,
|
|
2911
|
+
" Both commit watermarks are unchanged and no parse was scheduled.",
|
|
2912
|
+
" A human must resolve the branch itself; there is no override.",
|
|
2913
|
+
pin,
|
|
2914
|
+
];
|
|
2915
|
+
case "history_limit_exceeded":
|
|
2916
|
+
return [
|
|
2917
|
+
`Index scope ${scopeId} is too far behind its epic branch to replay.`,
|
|
2918
|
+
" Refusing rather than replaying unbounded history. Cut a fresh scope.",
|
|
2919
|
+
pin,
|
|
2920
|
+
];
|
|
2921
|
+
case "refused_lifecycle":
|
|
2922
|
+
return [
|
|
2923
|
+
`Index scope ${scopeId} cannot take a catch-up in its current state.`,
|
|
2924
|
+
` Reason: ${value.reason ?? "unknown"}`,
|
|
2925
|
+
pin,
|
|
2926
|
+
];
|
|
2927
|
+
case "unavailable":
|
|
2928
|
+
return [
|
|
2929
|
+
`The guarded catch-up could not read the epic branch for scope ${scopeId}.`,
|
|
2930
|
+
` Reason: ${value.reason ?? "unknown"}`,
|
|
2931
|
+
" An unread branch is never a current one. Retry once the provider recovers.",
|
|
2932
|
+
pin,
|
|
2933
|
+
];
|
|
2934
|
+
default:
|
|
2935
|
+
return [
|
|
2936
|
+
`The guarded catch-up did not repair index scope ${scopeId}.`,
|
|
2937
|
+
` Outcome: ${value.outcome ?? "unknown"} Reason: ${value.reason ?? "unknown"}`,
|
|
2938
|
+
pin,
|
|
2939
|
+
];
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/** `implement-epic retire` — start the retention clock; delete nothing. */
|
|
2943
|
+
export async function runConductEpicRetire(deps, options) {
|
|
2944
|
+
const accessProbe = await resolveAccess(deps);
|
|
2945
|
+
if (!accessProbe.ok)
|
|
2946
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
2947
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
2948
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
2949
|
+
if (!target.ok)
|
|
2950
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
2951
|
+
const outcome = await retireScopeWithEpoch(deps, options, accessProbe.access, target);
|
|
2952
|
+
if (!outcome.ok)
|
|
2953
|
+
return emitFailure(deps, options.json, [outcome.reason]);
|
|
2954
|
+
return emitSuccess(deps, options.json, {
|
|
2955
|
+
ok: true,
|
|
2956
|
+
epic_key: options.epicKey,
|
|
2957
|
+
scope_id: outcome.state.scope_id,
|
|
2958
|
+
lifecycle_state: outcome.state.lifecycle_state,
|
|
2959
|
+
lease_epoch: outcome.state.lease_epoch,
|
|
2960
|
+
already_retired: outcome.state.already_retired,
|
|
2961
|
+
}, [
|
|
2962
|
+
outcome.state.already_retired
|
|
2963
|
+
? `Index scope ${outcome.state.scope_id} was already retired; retention clock unchanged.`
|
|
2964
|
+
: `Retired index scope ${outcome.state.scope_id}. Nothing was deleted.`,
|
|
2965
|
+
" The scope stays readable for post-mortem for the whole retention window.",
|
|
2966
|
+
]);
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Retire a scope, resolving the fencing epoch the server currently holds.
|
|
2970
|
+
*
|
|
2971
|
+
* The epoch is the one thing retirement needs and the one thing a local
|
|
2972
|
+
* checkpoint can be wrong about — an operator `recover` (or another conductor)
|
|
2973
|
+
* may have superseded it. So a locally-cached epoch is used only as a first
|
|
2974
|
+
* attempt; on a fencing refusal the CURRENT epoch is read from the server's
|
|
2975
|
+
* listing and the retirement is retried ONCE. That is not a retry loop papering
|
|
2976
|
+
* over a race: retirement is idempotent and the second attempt uses an epoch the
|
|
2977
|
+
* server itself just reported.
|
|
2978
|
+
*/
|
|
2979
|
+
async function retireScopeWithEpoch(deps, options, access, target) {
|
|
2980
|
+
let epoch = target.leaseEpoch;
|
|
2981
|
+
if (epoch === null) {
|
|
2982
|
+
const current = await lookupScopeEpoch(deps, access, target.scopeId);
|
|
2983
|
+
if (current === null) {
|
|
2984
|
+
return {
|
|
2985
|
+
ok: false,
|
|
2986
|
+
reason: `The current fencing epoch for scope ${target.scopeId} could not be read.`,
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
epoch = current;
|
|
2990
|
+
}
|
|
2991
|
+
let retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
2992
|
+
if (!retired.ok) {
|
|
2993
|
+
const current = await lookupScopeEpoch(deps, access, target.scopeId);
|
|
2994
|
+
if (current !== null && current !== epoch) {
|
|
2995
|
+
epoch = current;
|
|
2996
|
+
retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
if (!retired.ok) {
|
|
3000
|
+
return { ok: false, reason: `The index scope could not be retired: ${retired.error}` };
|
|
3001
|
+
}
|
|
3002
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
3003
|
+
await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, retired.value.lease_epoch);
|
|
3004
|
+
return { ok: true, state: retired.value };
|
|
3005
|
+
}
|
|
3006
|
+
/** Read one scope's CURRENT fencing epoch from the authoritative listing. */
|
|
3007
|
+
async function lookupScopeEpoch(deps, access, scopeId) {
|
|
3008
|
+
const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
|
|
3009
|
+
if (!listing.ok)
|
|
3010
|
+
return null;
|
|
3011
|
+
const entry = listing.value.scopes.find((scope) => scope.scope_id === scopeId);
|
|
3012
|
+
return entry?.lease_epoch ?? null;
|
|
3013
|
+
}
|
|
3014
|
+
/** `implement-epic reclaim` — ask the server to schedule the scope's teardown. */
|
|
3015
|
+
export async function runConductEpicReclaim(deps, options) {
|
|
3016
|
+
const accessProbe = await resolveAccess(deps);
|
|
3017
|
+
if (!accessProbe.ok)
|
|
3018
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
3019
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
3020
|
+
const target = await resolveLifecycleScope(deps, options, checkpointPath);
|
|
3021
|
+
if (!target.ok)
|
|
3022
|
+
return emitFailure(deps, options.json, [target.reason]);
|
|
3023
|
+
const scheduled = await reclaimIndexScope(accessProbe.access, { scopeId: target.scopeId, overrideRetention: options.overrideRetention }, deps.fetchImpl);
|
|
3024
|
+
if (!scheduled.ok) {
|
|
3025
|
+
// A refusal names the BLOCKERS the server evaluated, so an operator learns
|
|
3026
|
+
// that a parse is running rather than that "reclaim failed".
|
|
3027
|
+
const blockers = scheduled.blockers ?? [];
|
|
3028
|
+
const reasons = [`The index scope could not be reclaimed: ${scheduled.error}`];
|
|
3029
|
+
if (blockers.length > 0)
|
|
3030
|
+
reasons.push(` blocked by: ${blockers.join(", ")}`);
|
|
3031
|
+
return emitFailure(deps, options.json, reasons, {
|
|
3032
|
+
epic_key: options.epicKey,
|
|
3033
|
+
scope_id: target.scopeId,
|
|
3034
|
+
blockers,
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
return emitSuccess(deps, options.json, {
|
|
3038
|
+
ok: true,
|
|
3039
|
+
epic_key: options.epicKey,
|
|
3040
|
+
scope_id: scheduled.value.scope_id ?? target.scopeId,
|
|
3041
|
+
scheduled: scheduled.value.scheduled,
|
|
3042
|
+
}, [
|
|
3043
|
+
`Scheduled reclamation of index scope ${scheduled.value.scope_id ?? target.scopeId}.`,
|
|
3044
|
+
" This is SCHEDULED, not done — the teardown waits out Pinecone's",
|
|
3045
|
+
" consistency window. Run `implement-epic status --json` to see it reach",
|
|
3046
|
+
" `reclaimed`.",
|
|
3047
|
+
]);
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* `implement-epic scopes` — the repository-wide index-scope inventory (BAPI-963).
|
|
3051
|
+
*
|
|
3052
|
+
* `--help` promised this listing under `status` and no invocation delivered it:
|
|
3053
|
+
* `implement-epic scopes` answered `Unknown verb` and `status <EPIC> --scopes`
|
|
3054
|
+
* answered `Unknown flag`, so an operator asking exactly the question the help
|
|
3055
|
+
* advertised was sent to SQL. The capability already existed — `status` pushes a
|
|
3056
|
+
* stranded-scope warning to stderr — only its documented entry point did not.
|
|
3057
|
+
*
|
|
3058
|
+
* Repository-scoped by construction: it resolves access, reads the lifecycle
|
|
3059
|
+
* listing ONCE, and takes no epic key, no checkpoint, and no lease.
|
|
3060
|
+
*/
|
|
3061
|
+
export async function runConductEpicScopes(deps, options) {
|
|
3062
|
+
const accessProbe = await resolveAccess(deps);
|
|
3063
|
+
if (!accessProbe.ok)
|
|
3064
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
3065
|
+
const listing = await getIndexScopeLifecycle(accessProbe.access, deps.fetchImpl);
|
|
3066
|
+
if (!listing.ok) {
|
|
3067
|
+
return emitFailure(deps, options.json, [
|
|
3068
|
+
`The index-scope inventory could not be read: ${listing.error}`,
|
|
3069
|
+
]);
|
|
3070
|
+
}
|
|
3071
|
+
return emitSuccess(deps, options.json, {
|
|
3072
|
+
ok: true,
|
|
3073
|
+
repo_name: accessProbe.access.repoName,
|
|
3074
|
+
retention_seconds: listing.value.retention_seconds,
|
|
3075
|
+
// The COMPLETE listing, unfiltered. `status` narrows deliberately; this verb
|
|
3076
|
+
// exists because that narrowing is the wrong answer to "what exists?".
|
|
3077
|
+
scopes: listing.value.scopes,
|
|
3078
|
+
}, renderIndexScopeInventoryLines(listing.value.scopes, accessProbe.access.repoName));
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Render the FULL scope inventory for `implement-epic scopes` (BAPI-963).
|
|
3082
|
+
*
|
|
3083
|
+
* Deliberately unlike {@link renderStrandedScopeLines}, which stays narrow: that
|
|
3084
|
+
* one prints only actionable wreckage on a status tick an operator reads every
|
|
3085
|
+
* few minutes, and widening it would turn a warning into wallpaper. This one
|
|
3086
|
+
* answers a question the operator asked on purpose, so it hides nothing —
|
|
3087
|
+
* reclaimed tombstones included, since "it is already gone" is an answer.
|
|
3088
|
+
*/
|
|
3089
|
+
function renderIndexScopeInventoryLines(scopes, repoName) {
|
|
3090
|
+
if (scopes.length === 0) {
|
|
3091
|
+
return [`${repoName} owns no index scopes.`];
|
|
3092
|
+
}
|
|
3093
|
+
const lines = [`${repoName} owns ${scopes.length} index scope(s):`];
|
|
3094
|
+
for (const scope of scopes) {
|
|
3095
|
+
lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
|
|
3096
|
+
`branch=${scope.feature_branch ?? "unknown"} ` +
|
|
3097
|
+
`lease=${scope.lease_valid ? "live" : "expired"} ` +
|
|
3098
|
+
`retention=${scope.retention_elapsed ? "elapsed" : (scope.retention_deadline ?? "none")} ` +
|
|
3099
|
+
`recoverable=${scope.recoverable ? "yes" : "no"}`);
|
|
3100
|
+
if (scope.blockers.length > 0) {
|
|
3101
|
+
lines.push(` blocked by: ${scope.blockers.join(", ")}`);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
return lines;
|
|
3105
|
+
}
|
|
3106
|
+
// ---------------------------------------------------------------------------
|
|
3107
|
+
// Entry point
|
|
3108
|
+
// ---------------------------------------------------------------------------
|
|
3109
|
+
/**
|
|
3110
|
+
* Entry point for the packaged `implement-epic` subcommand.
|
|
3111
|
+
*
|
|
3112
|
+
* Returns a numeric exit code and never throws for user-facing failure: `0` for
|
|
3113
|
+
* help and success, `1` for everything else with a concise stderr reason.
|
|
3114
|
+
*
|
|
3115
|
+
* S4 (BAPI-1080): renamed from `runConductEpicCli` (the packaged positional
|
|
3116
|
+
* command moved from `conduct-epic` to `implement-epic`). Internal
|
|
3117
|
+
* `ConductEpic*` type names, the checkpoint schema, `CONDUCT_EPIC_*`
|
|
3118
|
+
* constants/env vars, and `~/.config/bridge/conduct/` storage paths are
|
|
3119
|
+
* unchanged — they are owned by later slices.
|
|
3120
|
+
*/
|
|
3121
|
+
export async function runImplementEpicCli(argv, overrides = {}) {
|
|
3122
|
+
const deps = { ...createDefaultConductEpicDeps(), ...overrides };
|
|
3123
|
+
const parsed = parseConductEpicArgs(argv);
|
|
3124
|
+
if (parsed.status === "help") {
|
|
3125
|
+
deps.log(parsed.usage);
|
|
3126
|
+
return 0;
|
|
3127
|
+
}
|
|
3128
|
+
if (parsed.status === "error") {
|
|
3129
|
+
deps.errorLog(parsed.message);
|
|
3130
|
+
// `--json` is parsed alongside the failing token, so a malformed invocation
|
|
3131
|
+
// cannot be trusted to have set it. Failures from the parser are therefore
|
|
3132
|
+
// reported on stderr only, and the exit code carries the outcome.
|
|
3133
|
+
return 1;
|
|
3134
|
+
}
|
|
3135
|
+
const options = parsed.options;
|
|
3136
|
+
switch (options.verb) {
|
|
3137
|
+
case "scopes":
|
|
3138
|
+
return runConductEpicScopes(deps, options);
|
|
3139
|
+
case "init":
|
|
3140
|
+
return runConductEpicInit(deps, options);
|
|
3141
|
+
case "status":
|
|
3142
|
+
return runConductEpicStatus(deps, options);
|
|
3143
|
+
case "checkpoint-set":
|
|
3144
|
+
return runConductEpicCheckpointSet(deps, options);
|
|
3145
|
+
case "spawn":
|
|
3146
|
+
return runConductEpicSpawn(deps, options);
|
|
3147
|
+
case "finish":
|
|
3148
|
+
return runConductEpicFinish(deps, options);
|
|
3149
|
+
case "recover":
|
|
3150
|
+
return runConductEpicRecover(deps, options);
|
|
3151
|
+
case "retire":
|
|
3152
|
+
return runConductEpicRetire(deps, options);
|
|
3153
|
+
case "reclaim":
|
|
3154
|
+
return runConductEpicReclaim(deps, options);
|
|
3155
|
+
case "catch-up":
|
|
3156
|
+
return runConductEpicCatchUp(deps, options);
|
|
3157
|
+
}
|
|
3158
|
+
}
|