@bridge_gpt/mcp-server 0.2.53 → 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 +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- 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/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- 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 +134 -98
- 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 +120 -4
- 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/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- 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 +145 -18
- package/build/drive-epic.js +752 -90
- 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/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -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 +334 -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 +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- 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 +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-634: thin CLI handler exposing the deterministic conductor-bundle helpers
|
|
3
|
+
* to `emit-conductor-bundle.md`.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the instruction ships **compiled** to consuming repositories
|
|
6
|
+
* inside `build/pipelines.generated.js`. `mcp_server/src/` does not exist there,
|
|
7
|
+
* so an instruction that told the agent to "call `validateConductorBundleInputs`"
|
|
8
|
+
* would be referencing a path only this repository has. This subcommand is the
|
|
9
|
+
* reachable surface, mirroring how `store-and-approve-epic-plan.md` invokes
|
|
10
|
+
* `setup-epic` rather than hand-rolling HTTP calls.
|
|
11
|
+
*
|
|
12
|
+
* It holds no policy of its own: it parses argv, delegates to
|
|
13
|
+
* `conductor-bundle-artifacts.ts`, and prints a JSON envelope. All I/O is behind
|
|
14
|
+
* an injectable seam so tests never touch a real disk or construct a server.
|
|
15
|
+
*
|
|
16
|
+
* S4 (BAPI-1080): this file and its exported public surface were renamed from
|
|
17
|
+
* `conductor-bundle-cli.ts` (`emit-conductor-bundle` -> `emit-epic-implementer-bundle`).
|
|
18
|
+
* The delegated artifact helper module below, `conductor-bundle-artifacts.ts`
|
|
19
|
+
* (and its `validateConductorBundleInputs` export), intentionally keeps its
|
|
20
|
+
* current name — deferred to a later slice.
|
|
21
|
+
*/
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { finalizeEpicPlanSidecar, serializeSiblingTicketManifest, validateConductorBundleInputs, writeJsonAtomically, } from "./conductor-bundle-artifacts.js";
|
|
24
|
+
const USAGE = [
|
|
25
|
+
"Usage: emit-epic-implementer-bundle <validate|finalize> --input <file> [--docs-dir <dir>] [--json]",
|
|
26
|
+
"",
|
|
27
|
+
" validate Validate identities, the node->ticket mapping, manifest agreement,",
|
|
28
|
+
" and path containment. Writes nothing.",
|
|
29
|
+
" finalize Validate, then finalize epic-plan.dag.json with real keys and",
|
|
30
|
+
" per-node touched_files, and write the sibling-ticket manifest.",
|
|
31
|
+
"",
|
|
32
|
+
"Options:",
|
|
33
|
+
" --input <file> JSON document with epic_key, epic_slug, mappings,",
|
|
34
|
+
" decomposition_fingerprint, and (for finalize)",
|
|
35
|
+
" touched_files_by_key.",
|
|
36
|
+
" --docs-dir <dir> Docs directory (default: $BAPI_DOCS_DIR, else docs/tmp).",
|
|
37
|
+
" --json Emit a machine-readable result on stdout.",
|
|
38
|
+
" -h, --help Show this help.",
|
|
39
|
+
].join("\n");
|
|
40
|
+
/** Read a flag's value, rejecting a missing or flag-shaped value. */
|
|
41
|
+
function takeValue(argv, index, flag) {
|
|
42
|
+
const value = argv[index + 1];
|
|
43
|
+
if (value === undefined || value.startsWith("-")) {
|
|
44
|
+
throw new Error(`Flag "${flag}" requires a value.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
/** Parse argv into options, or throw with an actionable message. */
|
|
49
|
+
export function parseEpicImplementerBundleArgs(argv) {
|
|
50
|
+
const mode = argv[0];
|
|
51
|
+
if (mode === "-h" || mode === "--help" || mode === undefined) {
|
|
52
|
+
return { mode: "validate", inputFile: "", json: false, help: true };
|
|
53
|
+
}
|
|
54
|
+
if (mode !== "validate" && mode !== "finalize") {
|
|
55
|
+
throw new Error(`Unknown subcommand "${mode}". Expected "validate" or "finalize".`);
|
|
56
|
+
}
|
|
57
|
+
let inputFile;
|
|
58
|
+
let docsDir;
|
|
59
|
+
let json = false;
|
|
60
|
+
let help = false;
|
|
61
|
+
for (let i = 1; i < argv.length; i++) {
|
|
62
|
+
const arg = argv[i];
|
|
63
|
+
switch (arg) {
|
|
64
|
+
case "--input":
|
|
65
|
+
inputFile = takeValue(argv, i, "--input");
|
|
66
|
+
i++;
|
|
67
|
+
break;
|
|
68
|
+
case "--docs-dir":
|
|
69
|
+
docsDir = takeValue(argv, i, "--docs-dir");
|
|
70
|
+
i++;
|
|
71
|
+
break;
|
|
72
|
+
case "--json":
|
|
73
|
+
json = true;
|
|
74
|
+
break;
|
|
75
|
+
case "-h":
|
|
76
|
+
case "--help":
|
|
77
|
+
help = true;
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`Unknown argument "${arg}".`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (help)
|
|
84
|
+
return { mode, inputFile: inputFile ?? "", docsDir, json, help: true };
|
|
85
|
+
if (!inputFile)
|
|
86
|
+
throw new Error('Flag "--input" is required.');
|
|
87
|
+
return { mode, inputFile, docsDir, json, help: false };
|
|
88
|
+
}
|
|
89
|
+
/** Parse JSON, converting a syntax error into an actionable message. */
|
|
90
|
+
async function readJson(filePath, fs, label) {
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = await fs.readFile(filePath);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw new Error(`${label} at ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Read an optional JSON file, returning null when it does not exist. */
|
|
106
|
+
async function readOptionalJson(filePath, fs, label) {
|
|
107
|
+
try {
|
|
108
|
+
await fs.readFile(filePath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return readJson(filePath, fs, label);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run the `emit-epic-implementer-bundle` subcommand. Returns a process exit
|
|
117
|
+
* code; never throws for an expected validation failure.
|
|
118
|
+
*
|
|
119
|
+
* S4 (BAPI-1080): renamed from `runConductorBundleCli`.
|
|
120
|
+
*/
|
|
121
|
+
export async function runEpicImplementerBundleCli(argv, overrides = {}) {
|
|
122
|
+
const deps = {
|
|
123
|
+
env: process.env,
|
|
124
|
+
cwd: process.cwd(),
|
|
125
|
+
fs: createDefaultBundleFs(),
|
|
126
|
+
log: (m) => console.log(m),
|
|
127
|
+
// Diagnostics go to stderr so `--json` keeps stdout a single result object.
|
|
128
|
+
errorLog: (m) => console.error(m),
|
|
129
|
+
...overrides,
|
|
130
|
+
};
|
|
131
|
+
let opts;
|
|
132
|
+
try {
|
|
133
|
+
opts = parseEpicImplementerBundleArgs(argv);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
deps.errorLog(err instanceof Error ? err.message : String(err));
|
|
137
|
+
deps.errorLog(USAGE);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
if (opts.help) {
|
|
141
|
+
deps.log(USAGE);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const emitFailure = (error) => {
|
|
145
|
+
if (opts.json)
|
|
146
|
+
deps.log(JSON.stringify({ ok: false, error }));
|
|
147
|
+
else
|
|
148
|
+
deps.errorLog(`Error: ${error}`);
|
|
149
|
+
return 1;
|
|
150
|
+
};
|
|
151
|
+
const docsDir = path.resolve(deps.cwd, opts.docsDir ?? deps.env.BAPI_DOCS_DIR ?? path.join("docs", "tmp"));
|
|
152
|
+
let input;
|
|
153
|
+
try {
|
|
154
|
+
input = (await readJson(path.resolve(deps.cwd, opts.inputFile), deps.fs, "--input document"));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return emitFailure(err instanceof Error ? err.message : String(err));
|
|
158
|
+
}
|
|
159
|
+
if (!input || typeof input !== "object") {
|
|
160
|
+
return emitFailure("--input document must be a JSON object.");
|
|
161
|
+
}
|
|
162
|
+
const epicDir = path.resolve(docsDir, "epic-plans", String(input.epic_slug));
|
|
163
|
+
const sidecarPath = path.join(epicDir, "epic-plan.dag.json");
|
|
164
|
+
const manifestPath = path.join(epicDir, "sibling-ticket-manifest.json");
|
|
165
|
+
let sidecar;
|
|
166
|
+
let existingManifest;
|
|
167
|
+
try {
|
|
168
|
+
sidecar = await readJson(sidecarPath, deps.fs, "epic-plan.dag.json");
|
|
169
|
+
existingManifest = await readOptionalJson(manifestPath, deps.fs, "sibling-ticket-manifest.json");
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return emitFailure(err instanceof Error ? err.message : String(err));
|
|
173
|
+
}
|
|
174
|
+
const validation = await validateConductorBundleInputs({
|
|
175
|
+
epic_key: input.epic_key,
|
|
176
|
+
epic_slug: input.epic_slug,
|
|
177
|
+
docs_dir: docsDir,
|
|
178
|
+
mappings: input.mappings,
|
|
179
|
+
sidecar,
|
|
180
|
+
existing_manifest: existingManifest ?? undefined,
|
|
181
|
+
decomposition_fingerprint: input.decomposition_fingerprint,
|
|
182
|
+
}, deps.fs);
|
|
183
|
+
if (!validation.ok)
|
|
184
|
+
return emitFailure(validation.error);
|
|
185
|
+
if (opts.mode === "validate") {
|
|
186
|
+
const result = {
|
|
187
|
+
ok: true,
|
|
188
|
+
mode: "validate",
|
|
189
|
+
epic_key: validation.value.epic_key,
|
|
190
|
+
plan_version: validation.value.plan_version,
|
|
191
|
+
mapped_nodes: validation.value.mappings.length,
|
|
192
|
+
sidecar_path: validation.value.sidecar_path,
|
|
193
|
+
};
|
|
194
|
+
if (opts.json)
|
|
195
|
+
deps.log(JSON.stringify(result));
|
|
196
|
+
else {
|
|
197
|
+
deps.log(`Validated ${result.mapped_nodes} node mapping(s) for ${result.epic_key} ` +
|
|
198
|
+
`(plan v${result.plan_version}). Nothing was written.`);
|
|
199
|
+
}
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
// ---- finalize ----------------------------------------------------------
|
|
203
|
+
const nodeKeyMap = {};
|
|
204
|
+
for (const m of validation.value.mappings) {
|
|
205
|
+
nodeKeyMap[m.plan_node_id] = m.ticket_key;
|
|
206
|
+
}
|
|
207
|
+
const finalized = finalizeEpicPlanSidecar({
|
|
208
|
+
sidecar,
|
|
209
|
+
node_key_map: nodeKeyMap,
|
|
210
|
+
touched_files_by_key: input.touched_files_by_key ?? {},
|
|
211
|
+
plan_version_already_stored: input.plan_version_already_stored === true,
|
|
212
|
+
});
|
|
213
|
+
if (!finalized.ok)
|
|
214
|
+
return emitFailure(finalized.error);
|
|
215
|
+
const sidecarWrite = await writeJsonAtomically(validation.value.sidecar_path, finalized.value, deps.fs);
|
|
216
|
+
if (!sidecarWrite.ok)
|
|
217
|
+
return emitFailure(sidecarWrite.error);
|
|
218
|
+
const manifest = serializeSiblingTicketManifest({
|
|
219
|
+
schema_version: 1,
|
|
220
|
+
epic_key: validation.value.epic_key,
|
|
221
|
+
epic_slug: validation.value.epic_slug,
|
|
222
|
+
plan_version: validation.value.plan_version,
|
|
223
|
+
decomposition_fingerprint: input.decomposition_fingerprint,
|
|
224
|
+
finalized_fingerprint: null,
|
|
225
|
+
run_phase: input.run_phase ?? "staged",
|
|
226
|
+
mappings: validation.value.mappings.map((m) => ({
|
|
227
|
+
plan_node_id: m.plan_node_id,
|
|
228
|
+
ticket_key: m.ticket_key,
|
|
229
|
+
exploration_path: m.exploration_path,
|
|
230
|
+
draft_path: m.draft_path,
|
|
231
|
+
})),
|
|
232
|
+
decisions: [],
|
|
233
|
+
completed_mutations: [],
|
|
234
|
+
});
|
|
235
|
+
const manifestWrite = await writeJsonAtomically(validation.value.manifest_path, manifest, deps.fs);
|
|
236
|
+
if (!manifestWrite.ok)
|
|
237
|
+
return emitFailure(manifestWrite.error);
|
|
238
|
+
const result = {
|
|
239
|
+
ok: true,
|
|
240
|
+
mode: "finalize",
|
|
241
|
+
epic_key: validation.value.epic_key,
|
|
242
|
+
plan_version: validation.value.plan_version,
|
|
243
|
+
sidecar_path: validation.value.sidecar_path,
|
|
244
|
+
manifest_path: validation.value.manifest_path,
|
|
245
|
+
ticket_keys: validation.value.mappings.map((m) => m.ticket_key),
|
|
246
|
+
};
|
|
247
|
+
if (opts.json)
|
|
248
|
+
deps.log(JSON.stringify(result));
|
|
249
|
+
else {
|
|
250
|
+
deps.log(`Finalized ${result.sidecar_path} with ${result.ticket_keys.length} real key(s) ` +
|
|
251
|
+
`and per-node touched_files. Manifest: ${result.manifest_path}`);
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
/** Real filesystem seam, loaded lazily so importing this module stays cheap. */
|
|
256
|
+
function createDefaultBundleFs() {
|
|
257
|
+
return {
|
|
258
|
+
readFile: async (p) => (await import("node:fs/promises")).readFile(p, "utf-8"),
|
|
259
|
+
writeFile: async (p, data) => (await import("node:fs/promises")).writeFile(p, data, "utf-8"),
|
|
260
|
+
rename: async (from, to) => (await import("node:fs/promises")).rename(from, to),
|
|
261
|
+
unlink: async (p) => (await import("node:fs/promises")).unlink(p),
|
|
262
|
+
realpath: async (p) => (await import("node:fs/promises")).realpath(p),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from"node:path";import{fileURLToPath}from"node:url";import{spawnSync}from"node:child_process";import{readFileSync}from"node:fs";var CONDUCTOR_HOOK_EMIT_FAILED_WARNING="Warning: conductor hook emit failed.";function resolveClaudeHookEventName(payload){if(payload===null||typeof payload!="object"||Array.isArray(payload))return null;let record=payload,candidates=["hook_event_name","hookEventName","event_name","event"];for(let key of candidates){let value=record[key];if(typeof value=="string"&&value.trim().length>0)return value.trim()}return null}function mapClaudeHookEventToSemanticType(eventName){switch(eventName){case"SessionStart":return"run.started";case"SessionEnd":return"run.stopped";case"Stop":return null;case"SubagentStop":return null;case"Notification":return"agent.notification";case"PreToolUse":return"tool.intent";default:return null}}var REQUIRED_IDENTITY_ENV=["BAPI_CONDUCTOR_RUN_ID","BAPI_CONDUCTOR_WORKER_ID","BAPI_CONDUCTOR_TICKET_KEY","BAPI_CONDUCTOR_WORKTREE_PATH"];function nonEmpty(value){return typeof value=="string"&&value.trim().length>0}function buildClaudeHookConductorEvent(payload,env){let eventName=resolveClaudeHookEventName(payload),type=mapClaudeHookEventToSemanticType(eventName);if(type===null||env.BAPI_CONDUCTOR_ENABLED!=="1")return null;for(let key of REQUIRED_IDENTITY_ENV)if(!nonEmpty(env[key]))return null;let ticketKey=env.BAPI_CONDUCTOR_TICKET_KEY.trim(),worktreePath=env.BAPI_CONDUCTOR_WORKTREE_PATH.trim(),details={ticket_key:ticketKey,worktree_path:worktreePath};nonEmpty(env.BAPI_CONDUCTOR_REPO_NAME)&&(details.repo=env.BAPI_CONDUCTOR_REPO_NAME.trim());let raw=payload!==null&&typeof payload=="object"&&!Array.isArray(payload)?payload:{payload};return{source:"claude-code",type,subject:ticketKey,run_id:env.BAPI_CONDUCTOR_RUN_ID.trim(),worker_id:env.BAPI_CONDUCTOR_WORKER_ID.trim(),producer:"claude-code-hook",observed_via:"claude-code-hook",data:{details,raw}}}function buildConductorEmitEventArgs(event){let args=["--type",event.type,"--source",event.source];return nonEmpty(event.subject??void 0)&&args.push("--subject",event.subject),nonEmpty(event.run_id??void 0)&&args.push("--run-id",event.run_id),nonEmpty(event.worker_id??void 0)&&args.push("--worker-id",event.worker_id),nonEmpty(event.producer??void 0)&&args.push("--producer",event.producer),nonEmpty(event.observed_via??void 0)&&args.push("--observed-via",event.observed_via),args.push("--data-json-stdin","--json"),args}function resolveConductorEmitCommand(env,emitArgs){let cliFile=env.BAPI_CONDUCTOR_CLI_FILE;return nonEmpty(cliFile)?{command:process.execPath,args:[cliFile.trim(),"emit-event",...emitArgs]}:{command:nonEmpty(env.BAPI_CONDUCTOR_BIN)?env.BAPI_CONDUCTOR_BIN.trim():"epic-implementer",args:["emit-event",...emitArgs]}}var defaultConductorSpawn=(command,args,input)=>{let result=spawnSync(command,args,{input,encoding:"utf-8",shell:!1});return{status:result.status,error:result.error}};function emitClaudeHookEventWithConductorCli(event,deps={}){let env=deps.env??process.env,spawn=deps.spawn??defaultConductorSpawn,emitArgs=buildConductorEmitEventArgs(event),{command,args}=resolveConductorEmitCommand(env,emitArgs),stdinPayload=JSON.stringify(event.data??{});try{let result=spawn(command,args,stdinPayload);return result.error?!1:result.status===0}catch{return!1}}function runClaudeConductorHookCli(deps={}){let env=deps.env??process.env,readStdin=deps.readStdin??(()=>readStdinSync()),warn=deps.warn??(m=>process.stderr.write(`${m}
|
|
3
|
+
`)),payload;try{let raw=readStdin();payload=JSON.parse(raw)}catch{return warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING),0}let event;try{event=buildClaudeHookConductorEvent(payload,env)}catch{return warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING),0}return event===null||emitClaudeHookEventWithConductorCli(event,{env,spawn:deps.spawn})||warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING),0}function readStdinSync(){return readFileSync(0,"utf-8")}function runEpicImplementerClaudeHookBin(deps={}){let handler=deps.handler??runClaudeConductorHookCli,exit=deps.exit??process.exit.bind(process),exitCode=0;try{exitCode=handler()}catch{exitCode=0}exit(exitCode)}var invokedDirectly=process.argv[1]!==void 0&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url);invokedDirectly&&runEpicImplementerClaudeHookBin();export{runEpicImplementerClaudeHookBin};
|
|
@@ -30,9 +30,11 @@ export const EPIC_BRANCH_PREFIX = "epic/";
|
|
|
30
30
|
/** The production call sites this helper is invoked from. */
|
|
31
31
|
export const EPIC_INTEGRATION_PR_COMMANDS = [
|
|
32
32
|
"setup-epic",
|
|
33
|
-
"conduct-epic init"
|
|
34
|
-
"conduct-epic
|
|
35
|
-
"
|
|
33
|
+
// S4 (BAPI-1080): renamed from "conduct-epic init" / "conduct-epic catch-up"
|
|
34
|
+
// / "conduct-epic finish".
|
|
35
|
+
"implement-epic init",
|
|
36
|
+
"implement-epic catch-up",
|
|
37
|
+
"implement-epic finish",
|
|
36
38
|
"executor merge",
|
|
37
39
|
// BAPI-1010: the executor's one-shot wind-down observer, distinct from
|
|
38
40
|
// "executor merge" so the generated PR body and diagnostics never mislabel
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The executor CLAIM SCOPE: one vocabulary, one validator, two commands.
|
|
3
|
+
*
|
|
4
|
+
* BAPI-1026 (R54) made the claim scope mandatory and explicit: an executor
|
|
5
|
+
* started with neither `--epic-run-id` nor `--repo-wide` REFUSES TO START,
|
|
6
|
+
* because the server no longer treats an omitted scope as repository-wide and
|
|
7
|
+
* such an executor would poll forever and claim nothing. Refusing loudly at
|
|
8
|
+
* startup replaced that silent idle.
|
|
9
|
+
*
|
|
10
|
+
* BAPI-1102 gave `plane up` executor lanes of its own to spawn, which made the
|
|
11
|
+
* refusal reachable from a second command — and made a copied string literal the
|
|
12
|
+
* obvious hazard. A plane that re-worded the executor's refusal would tell an
|
|
13
|
+
* operator something subtly different about the same rule, and the two wordings
|
|
14
|
+
* would drift the first time either was edited. So the vocabulary and the rule
|
|
15
|
+
* move HERE, and both CLIs render what this module returns, verbatim.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately free of process startup, filesystem access, network access, and
|
|
18
|
+
* CLI output. It is imported by `executor/cli.ts` and by `plane/`, and a shared
|
|
19
|
+
* module that reached for any of those would drag the whole executor runtime into
|
|
20
|
+
* the plane's import graph to validate two flags.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The exact refusal for an invocation carrying BOTH forms of scope.
|
|
24
|
+
*
|
|
25
|
+
* Module-level constants, not inline literals, and exported so a test can assert
|
|
26
|
+
* that the plane renders the executor's own wording rather than a lookalike.
|
|
27
|
+
* Neither interpolates anything: these are fixed prose about flags.
|
|
28
|
+
*/
|
|
29
|
+
export const EXECUTOR_CLAIM_SCOPE_MUTUALLY_EXCLUSIVE_MESSAGE = "--repo-wide cannot be combined with --epic-run-id: pass one claim scope, not both";
|
|
30
|
+
/**
|
|
31
|
+
* The shape of a server-minted epic run id (BAPI-794).
|
|
32
|
+
*
|
|
33
|
+
* `epic_run_id` is always minted as `str(uuid.uuid4())` in
|
|
34
|
+
* `api/library/db/epic_runs.py`, so a standard UUID shape — any version — is a
|
|
35
|
+
* cheap, safe pre-validation: it rejects an obvious typo before any credential
|
|
36
|
+
* resolution or executor-loop startup, without over-constraining the CLI to a
|
|
37
|
+
* version the server does not itself enforce.
|
|
38
|
+
*
|
|
39
|
+
* It lives HERE, with the rest of the scope vocabulary, rather than in
|
|
40
|
+
* `executor/cli.ts` where it began. `plane up` accepted any non-blank id and the
|
|
41
|
+
* executor it spawns then refused the same value at startup — the operator saw a
|
|
42
|
+
* plane come up and a lane die, instead of one refusal at the point of entry
|
|
43
|
+
* (BAPI-1102 review).
|
|
44
|
+
*/
|
|
45
|
+
export const EXECUTOR_EPIC_RUN_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
46
|
+
/** Is this a plausibly server-minted epic run id? */
|
|
47
|
+
export function isValidExecutorEpicRunId(value) {
|
|
48
|
+
return EXECUTOR_EPIC_RUN_ID_PATTERN.test(value.trim());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The refusal for an id that cannot be a server-minted run id. Interpolates the
|
|
52
|
+
* offending value, which is argv the operator just typed — never a credential.
|
|
53
|
+
*/
|
|
54
|
+
export function executorEpicRunIdRefusal(value) {
|
|
55
|
+
return `--epic-run-id has an invalid value: ${value}`;
|
|
56
|
+
}
|
|
57
|
+
/** The exact refusal for an invocation carrying NO scope at all. */
|
|
58
|
+
export const EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE = "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
|
|
59
|
+
"specific epic runs, or --repo-wide to deliberately claim repository-wide";
|
|
60
|
+
/**
|
|
61
|
+
* Validate a parsed scope selection.
|
|
62
|
+
*
|
|
63
|
+
* Order matters and is part of the contract: the mutual-exclusion refusal is
|
|
64
|
+
* reported BEFORE the missing-scope refusal, so an invocation that supplies both
|
|
65
|
+
* is told it supplied both rather than being told to supply one. One invocation
|
|
66
|
+
* therefore yields exactly one message, deterministically.
|
|
67
|
+
*
|
|
68
|
+
* Blank and whitespace-only ids are dropped before the emptiness test, so
|
|
69
|
+
* `--epic-run-id ""` is "no scope supplied" rather than a scope naming a run that
|
|
70
|
+
* cannot exist — the same reading the executor's own argv parse already takes.
|
|
71
|
+
*
|
|
72
|
+
* Each surviving id is then checked for the server-minted UUID shape, and that
|
|
73
|
+
* check runs LAST: an invocation with no scope at all is told to supply one
|
|
74
|
+
* rather than being told its zero ids are malformed.
|
|
75
|
+
*/
|
|
76
|
+
export function validateExecutorClaimScope(input) {
|
|
77
|
+
const epicRunIds = input.epicRunIds.map((id) => id.trim()).filter((id) => id.length > 0);
|
|
78
|
+
if (input.repoWide && epicRunIds.length > 0) {
|
|
79
|
+
return { ok: false, message: EXECUTOR_CLAIM_SCOPE_MUTUALLY_EXCLUSIVE_MESSAGE };
|
|
80
|
+
}
|
|
81
|
+
if (input.repoWide)
|
|
82
|
+
return { ok: true, scope: { kind: "repo-wide" } };
|
|
83
|
+
if (epicRunIds.length === 0) {
|
|
84
|
+
return { ok: false, message: EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE };
|
|
85
|
+
}
|
|
86
|
+
const malformed = epicRunIds.find((id) => !isValidExecutorEpicRunId(id));
|
|
87
|
+
if (malformed !== undefined) {
|
|
88
|
+
return { ok: false, message: executorEpicRunIdRefusal(malformed) };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, scope: { kind: "epic-runs", epicRunIds } };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Render a validated scope as executor argv, in the executor's own flag spelling.
|
|
94
|
+
*
|
|
95
|
+
* The single place that knows how a scope becomes arguments, so the plane's lane
|
|
96
|
+
* roster cannot spell the flags differently from the executor that parses them.
|
|
97
|
+
* One `--epic-run-id <id>` PAIR per id, in the supplied order; `--repo-wide`
|
|
98
|
+
* exactly once and valueless.
|
|
99
|
+
*/
|
|
100
|
+
export function executorClaimScopeArgs(scope) {
|
|
101
|
+
if (scope.kind === "repo-wide")
|
|
102
|
+
return ["--repo-wide"];
|
|
103
|
+
return scope.epicRunIds.flatMap((id) => ["--epic-run-id", id]);
|
|
104
|
+
}
|
package/build/executor/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { VERSION } from "../version.generated.js";
|
|
|
11
11
|
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
12
12
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
13
13
|
import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
|
|
14
|
+
import { executorEpicRunIdRefusal, isValidExecutorEpicRunId, validateExecutorClaimScope, } from "./claim-scope.js";
|
|
14
15
|
import { createDefaultExecutorDeps } from "./deps.js";
|
|
15
16
|
import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
16
17
|
import { createExecutorHttpClient } from "./http-client.js";
|
|
@@ -72,17 +73,6 @@ function parseIntArg(value, flag) {
|
|
|
72
73
|
}
|
|
73
74
|
return { ok: true, value: n };
|
|
74
75
|
}
|
|
75
|
-
/**
|
|
76
|
-
* BAPI-794 — `epic_run_id` is always server-minted as `str(uuid.uuid4())`
|
|
77
|
-
* (`api/library/db/epic_runs.py`), so a standard UUID shape (any version) is a
|
|
78
|
-
* cheap, safe pre-validation: it rejects an obvious typo before any credential
|
|
79
|
-
* resolution or executor-loop startup, without over-constraining the CLI to a
|
|
80
|
-
* version the server does not itself enforce.
|
|
81
|
-
*/
|
|
82
|
-
const EPIC_RUN_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
83
|
-
function isValidEpicRunId(value) {
|
|
84
|
-
return EPIC_RUN_ID_PATTERN.test(value);
|
|
85
|
-
}
|
|
86
76
|
/** Parse the executor CLI arguments into resolved options (pure, synchronous). */
|
|
87
77
|
export function parseExecutorArgs(argv, context) {
|
|
88
78
|
const repos = [];
|
|
@@ -121,8 +111,11 @@ export function parseExecutorArgs(argv, context) {
|
|
|
121
111
|
if (!v || !v.trim())
|
|
122
112
|
return { kind: "error", message: "--epic-run-id requires a value" };
|
|
123
113
|
const trimmed = v.trim();
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
// BAPI-1102 — the shape rule moved to the shared claim-scope module, so
|
|
115
|
+
// `plane up` refuses the same value at ITS entry point instead of coming
|
|
116
|
+
// up and letting the lane it spawned die on the same argument.
|
|
117
|
+
if (!isValidExecutorEpicRunId(trimmed)) {
|
|
118
|
+
return { kind: "error", message: executorEpicRunIdRefusal(trimmed) };
|
|
126
119
|
}
|
|
127
120
|
if (!epicRunIds.includes(trimmed))
|
|
128
121
|
epicRunIds.push(trimmed);
|
|
@@ -200,18 +193,14 @@ export function parseExecutorArgs(argv, context) {
|
|
|
200
193
|
// forever and claim nothing. Refusing to START is the loud failure that
|
|
201
194
|
// replaces that silent idle: a misconfiguration is a startup error, visible
|
|
202
195
|
// immediately, rather than an executor that looks healthy and does no work.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
if (!
|
|
210
|
-
return {
|
|
211
|
-
kind: "error",
|
|
212
|
-
message: "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
|
|
213
|
-
"specific epic runs, or --repo-wide to deliberately claim repository-wide",
|
|
214
|
-
};
|
|
196
|
+
//
|
|
197
|
+
// BAPI-1102 moved the rule and both messages into `./claim-scope.js` so
|
|
198
|
+
// `plane up` — which now spawns executor lanes of its own — enforces the SAME
|
|
199
|
+
// rule and renders the SAME wording, rather than carrying a second copy that
|
|
200
|
+
// drifts. The returned message is rendered VERBATIM; nothing is re-worded here.
|
|
201
|
+
const scope = validateExecutorClaimScope({ epicRunIds, repoWide });
|
|
202
|
+
if (!scope.ok) {
|
|
203
|
+
return { kind: "error", message: scope.message };
|
|
215
204
|
}
|
|
216
205
|
const options = {
|
|
217
206
|
executorId: executorIdFinal,
|
|
@@ -31,6 +31,27 @@
|
|
|
31
31
|
* all produce `ok: false`; only a second listing that shows no matching entry
|
|
32
32
|
* produces `ok: true`.
|
|
33
33
|
*
|
|
34
|
+
* TRACKED TEMPLATES SURVIVE (BAPI-1104, R81). The `.env.` prefix rule below is
|
|
35
|
+
* deliberately wide, and it caught this repository's own git-TRACKED
|
|
36
|
+
* `.env.example` — deleting it from every worker worktree, which showed up as a
|
|
37
|
+
* spurious dirty tree in the BAPI-1061 run. A tracked file is not an operator's
|
|
38
|
+
* environment file: it is repository content the worker is entitled to see, and
|
|
39
|
+
* its contents are already public in the checkout. So a candidate that git
|
|
40
|
+
* reports as tracked is kept, reported as `kept_tracked`, and excluded from the
|
|
41
|
+
* confirming re-list below.
|
|
42
|
+
*
|
|
43
|
+
* The exemption does NOT widen this module's reach. The lookup is a narrow seam
|
|
44
|
+
* that takes basenames and returns basenames — it never resolves, reads, or stats
|
|
45
|
+
* a path, so the symlink guarantee above is untouched. And it is best-effort by
|
|
46
|
+
* design: when the lookup fails, NOTHING is exempt and every match is stripped
|
|
47
|
+
* exactly as before. That is strictly the safer direction. The guarantee this
|
|
48
|
+
* module makes is about what reaches the worker, and it is proven by the
|
|
49
|
+
* post-removal re-list, not by knowing which files were tracked — so a failed
|
|
50
|
+
* lookup leaves the proof exactly as strong as it was and costs only the
|
|
51
|
+
* convenience the exemption was added for. Refusing the spawn instead would
|
|
52
|
+
* convert a transient git hiccup into a dead job, adding a failure mode the guard
|
|
53
|
+
* never had.
|
|
54
|
+
*
|
|
34
55
|
* REDACTION. A failure returns a fixed CATEGORY and nothing else — no filename,
|
|
35
56
|
* no path, no resolved target, no exception text. This value reaches the job row
|
|
36
57
|
* and the executor's stderr, and a worktree path contains an operator's username.
|
|
@@ -53,6 +74,29 @@ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
|
|
|
53
74
|
export function isWorkerEnvFileName(name) {
|
|
54
75
|
return name === ".env" || name.startsWith(".env.");
|
|
55
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* True for a name safe to hand to a subprocess as a literal pathspec (BAPI-1104).
|
|
79
|
+
*
|
|
80
|
+
* `readdir` cannot produce a name containing a separator or a NUL, so in
|
|
81
|
+
* production this is already true of every candidate. It is checked anyway
|
|
82
|
+
* because the value crosses a process boundary: the property that matters —
|
|
83
|
+
* "only exact `.env`/`.env.*` root basenames ever reach git" — should be
|
|
84
|
+
* enforced at the boundary rather than inferred from what `readdir` happens to
|
|
85
|
+
* guarantee. A name that fails this is simply not exempted; it is still stripped.
|
|
86
|
+
*
|
|
87
|
+
* Shell metacharacters need no handling here and none is attempted:
|
|
88
|
+
* `deps.runCommand` is `execFile` with `shell: false`, so a name like
|
|
89
|
+
* `.env; rm -rf /` is delivered as one inert argv element and no shell ever
|
|
90
|
+
* parses it. The real (narrow) hazard is git reading a leading-dash argument as
|
|
91
|
+
* an OPTION, which the `--` pathspec separator at the call site closes — and
|
|
92
|
+
* which the `.env` prefix rule above already makes unreachable.
|
|
93
|
+
*/
|
|
94
|
+
function isSafePathspecBasename(name) {
|
|
95
|
+
return (isWorkerEnvFileName(name) &&
|
|
96
|
+
!name.includes("/") &&
|
|
97
|
+
!name.includes("\\") &&
|
|
98
|
+
!name.includes("\0"));
|
|
99
|
+
}
|
|
56
100
|
/**
|
|
57
101
|
* Strip every `.env` / `.env.*` entry from a worktree ROOT and prove it worked.
|
|
58
102
|
*
|
|
@@ -74,7 +118,37 @@ export async function stripWorkerEnvFiles(worktreePath, deps) {
|
|
|
74
118
|
// Sorted so a successful `removed` list is deterministic and two job rows for
|
|
75
119
|
// the same condition render identically.
|
|
76
120
|
const matches = entries.filter(isWorkerEnvFileName).sort();
|
|
77
|
-
|
|
121
|
+
// BAPI-1104 — which matches are git-TRACKED repository content rather than an
|
|
122
|
+
// operator's environment file. Asked ONCE for the whole candidate set: one
|
|
123
|
+
// bounded subprocess at the last step before every worker spawn is a cost worth
|
|
124
|
+
// paying; one per `.env*` entry is not.
|
|
125
|
+
//
|
|
126
|
+
// Every value that comes back is re-intersected against `matches`. The seam is
|
|
127
|
+
// narrow but it is still a seam, and a lookup that returned a path, a nested
|
|
128
|
+
// name, or a name that was never a candidate must not be able to exempt
|
|
129
|
+
// anything — so the guard trusts the set it computed itself, not the answer.
|
|
130
|
+
let keptTracked = [];
|
|
131
|
+
const lookupCandidates = matches.filter(isSafePathspecBasename);
|
|
132
|
+
if (deps.listTrackedNames && lookupCandidates.length > 0) {
|
|
133
|
+
try {
|
|
134
|
+
const reported = await deps.listTrackedNames(worktreePath, lookupCandidates);
|
|
135
|
+
if (Array.isArray(reported)) {
|
|
136
|
+
const candidateSet = new Set(lookupCandidates);
|
|
137
|
+
keptTracked = Array.from(new Set(reported.filter((name) => typeof name === "string" && candidateSet.has(name)))).sort();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Exempt NOTHING and continue. The guarantee is proven by the re-list
|
|
142
|
+
// below, which a failed lookup does not weaken — so stripping the tracked
|
|
143
|
+
// file too is strictly safer than refusing a spawn the guard can still
|
|
144
|
+
// confirm clean. No category, no diagnostic: the caller that owns the
|
|
145
|
+
// subprocess owns the one bounded log line about it.
|
|
146
|
+
keptTracked = [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const keptSet = new Set(keptTracked);
|
|
150
|
+
const removed = matches.filter((name) => !keptSet.has(name));
|
|
151
|
+
for (const name of removed) {
|
|
78
152
|
const target = pathApi.join(worktreePath, name);
|
|
79
153
|
try {
|
|
80
154
|
// Non-following metadata. The result is deliberately unused for a decision:
|
|
@@ -106,8 +180,13 @@ export async function stripWorkerEnvFiles(worktreePath, deps) {
|
|
|
106
180
|
catch {
|
|
107
181
|
return { ok: false, category: "verification_listing_failed" };
|
|
108
182
|
}
|
|
109
|
-
|
|
183
|
+
// A KEPT tracked name is an expected survivor, not a failure. Without this the
|
|
184
|
+
// exemption would be self-defeating: the re-list would see the `.env.example`
|
|
185
|
+
// it deliberately preserved and refuse the spawn with `entry_survived`.
|
|
186
|
+
if (remaining.some((name) => isWorkerEnvFileName(name) && !keptSet.has(name))) {
|
|
110
187
|
return { ok: false, category: "entry_survived" };
|
|
111
188
|
}
|
|
112
|
-
return
|
|
189
|
+
return keptTracked.length > 0
|
|
190
|
+
? { ok: true, removed, kept_tracked: keptTracked }
|
|
191
|
+
: { ok: true, removed };
|
|
113
192
|
}
|
package/build/executor/env.js
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* (`Read(~/.config/bridge/**)` in `permissions.ts`).
|
|
54
54
|
*/
|
|
55
55
|
import { PR_BASE_BRANCH_ENV_VAR, WORKER_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
56
|
+
import { DECLARED_TOUCHED_FILES_ENV, normalizeDeclaredTouchedFiles, } from "../conductor/file-scope-guard.js";
|
|
56
57
|
import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
|
|
57
58
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
58
59
|
const ALLOWED_ENV_KEYS = [
|
|
@@ -232,6 +233,11 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
|
|
|
232
233
|
if (indexScope !== undefined) {
|
|
233
234
|
env[INDEX_SCOPE_ENV_VAR] = indexScope;
|
|
234
235
|
}
|
|
236
|
+
// BAPI-1152 — normalized through the guard's own normalizer, so an unsafe path
|
|
237
|
+
// (absolute, `..`) in the declaration is dropped rather than widening the scope.
|
|
238
|
+
if (Array.isArray(options.declaredTouchedFiles)) {
|
|
239
|
+
env[DECLARED_TOUCHED_FILES_ENV] = JSON.stringify(normalizeDeclaredTouchedFiles(options.declaredTouchedFiles));
|
|
240
|
+
}
|
|
235
241
|
// Nothing agent-specific is added here, and deliberately nothing secret. An
|
|
236
242
|
// adapter that needs to forward an operator-owned credential declares it as a
|
|
237
243
|
// passthrough and copies it onto the object this function returns; see
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup after a worker is interrupted — timed out or killed (BAPI-1152).
|
|
3
|
+
*
|
|
4
|
+
* In the BAPI-1111 run an implement worker was SIGTERMed at its wall-clock
|
|
5
|
+
* timeout while git held the index lock. The stale `index.lock` it left behind
|
|
6
|
+
* made the NEXT job's `git reset --hard` fail with `WorktreeError`, and the
|
|
7
|
+
* worktree only recovered through crashed-job recovery. This removes that residue
|
|
8
|
+
* the moment the executor knows the worker is gone.
|
|
9
|
+
*
|
|
10
|
+
* Scope is deliberately narrow, and the narrowness is the point (accepted review
|
|
11
|
+
* decision): for EVERY spawn job this only removes a stale `index.lock` and aborts
|
|
12
|
+
* an in-progress merge. It never hard-resets. A `resume` continues committed
|
|
13
|
+
* progress from a prior attempt and an implement worker may hold valid local
|
|
14
|
+
* commits when it times out — resetting them would destroy exactly the work the
|
|
15
|
+
* recovery path exists to keep. Only `reconcile_overlap`, whose anchor is its
|
|
16
|
+
* dispatch-time child head, additionally resets, through its own driver.
|
|
17
|
+
*
|
|
18
|
+
* Called ONLY after the worker process's `wait()` has resolved, i.e. after the
|
|
19
|
+
* executor has observed the child exit (after the termination controller's
|
|
20
|
+
* SIGTERM grace period and any SIGKILL). A lock held by a git grandchild that
|
|
21
|
+
* outlived the worker is not distinguishable from a stale one by this check —
|
|
22
|
+
* a known limit, stated rather than papered over; the worker's own git commands
|
|
23
|
+
* are short-lived, so the window is small. Never throws.
|
|
24
|
+
*/
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
27
|
+
export async function cleanupInterruptedWorktree(worktreePath, seams) {
|
|
28
|
+
const outcome = { removedIndexLock: false, abortedMerge: false };
|
|
29
|
+
try {
|
|
30
|
+
const gitDir = await seams.runCommand("git", ["rev-parse", "--absolute-git-dir"], {
|
|
31
|
+
cwd: worktreePath,
|
|
32
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
33
|
+
});
|
|
34
|
+
const dir = gitDir.stdout.trim();
|
|
35
|
+
if (gitDir.exitCode === 0 && dir.length > 0 && path.isAbsolute(dir)) {
|
|
36
|
+
try {
|
|
37
|
+
await seams.removeFile(path.join(dir, "index.lock"));
|
|
38
|
+
outcome.removedIndexLock = true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Absent is the common case and is not an error.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const mergeHead = await seams.runCommand("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
45
|
+
cwd: worktreePath,
|
|
46
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
47
|
+
});
|
|
48
|
+
if (mergeHead.exitCode === 0) {
|
|
49
|
+
const aborted = await seams.runCommand("git", ["merge", "--abort"], {
|
|
50
|
+
cwd: worktreePath,
|
|
51
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
52
|
+
});
|
|
53
|
+
outcome.abortedMerge = aborted.exitCode === 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
seams.errorLog("[executor] interrupted-worktree cleanup could not complete; the next preparation resets it");
|
|
58
|
+
}
|
|
59
|
+
return outcome;
|
|
60
|
+
}
|