@bridge_gpt/mcp-server 0.2.36 → 0.2.37
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 +44 -6
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/** Restrictive mode for the isolation directory itself (owner-only). */
|
|
2
|
+
export const ISOLATION_DIR_MODE = 0o700;
|
|
3
|
+
/** Owner-only mode for the seeded Bridge credential file. */
|
|
4
|
+
export const ISOLATION_FILE_MODE = 0o600;
|
|
5
|
+
/**
|
|
6
|
+
* Fixed name prefix for every directory this module creates. The startup sweep
|
|
7
|
+
* recognizes ONLY this prefix, which is what keeps it from deleting unrelated
|
|
8
|
+
* temporary directories that happen to share the parent.
|
|
9
|
+
*/
|
|
10
|
+
export const ISOLATION_DIR_PREFIX = "bapi-conductor-claude-cfg-";
|
|
11
|
+
/**
|
|
12
|
+
* Age past which the startup sweep may remove an orphan, deliberately far above
|
|
13
|
+
* any supported worker lifetime (the longest executor job timeout is measured in
|
|
14
|
+
* hours, not days). A directory younger than this may still belong to a LIVE
|
|
15
|
+
* worker owned by a concurrent executor process, so the threshold is the only
|
|
16
|
+
* thing preventing the sweep from deleting an in-use configuration.
|
|
17
|
+
*/
|
|
18
|
+
export const ISOLATION_ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
/** Platforms whose POSIX mode model supports the `0700` containment above. */
|
|
20
|
+
const SUPPORTED_PLATFORMS = ["darwin", "linux"];
|
|
21
|
+
/** Fixed remediation text per reason. Never includes a path or a value. */
|
|
22
|
+
const REASON_MESSAGES = {
|
|
23
|
+
"unsupported-platform": "worker config isolation is not supported on this platform (POSIX 0700/0600 containment is required)",
|
|
24
|
+
"unsupported-auth-layout": "no ANTHROPIC_API_KEY is available to the executor; an OAuth/subscription-only login does not " +
|
|
25
|
+
"follow an isolated CLAUDE_CONFIG_DIR, and copying operator credentials is not permitted",
|
|
26
|
+
"missing-filesystem-dependency": "the executor was constructed without the filesystem boundaries worker config isolation requires",
|
|
27
|
+
"unsafe-isolation-parent": "the isolation parent directory is a symbolic link or not a directory; refusing to create an " +
|
|
28
|
+
"isolated config directory beneath it",
|
|
29
|
+
"directory-creation-failed": "the isolated config directory could not be created or permissioned",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Render a capability failure as a bounded, secret-free sentence naming only the
|
|
33
|
+
* platform and the fixed reason — the exact text posted as an `error_message`.
|
|
34
|
+
*/
|
|
35
|
+
export function formatWorkerConfigIsolationReason(platform, reason) {
|
|
36
|
+
return `worker config isolation unavailable on ${platform}: ${REASON_MESSAGES[reason]}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether isolation CAN be established here, without creating anything.
|
|
40
|
+
*
|
|
41
|
+
* Read-only by construction — this is what lets `doctor` reuse it (BAPI-731
|
|
42
|
+
* requirement 4) while keeping doctor's no-writes contract. It never reads the
|
|
43
|
+
* operator's `.claude.json`, never touches the credential store, and never
|
|
44
|
+
* returns a credential value: the auth check is presence-only.
|
|
45
|
+
*/
|
|
46
|
+
export function evaluateWorkerConfigIsolationCapability(deps) {
|
|
47
|
+
const platform = String(deps.platform);
|
|
48
|
+
if (!SUPPORTED_PLATFORMS.includes(deps.platform)) {
|
|
49
|
+
return { supported: false, platform, reason: "unsupported-platform" };
|
|
50
|
+
}
|
|
51
|
+
// Presence-only. The VALUE is never read into any result, log, or diagnostic.
|
|
52
|
+
const apiKey = deps.env?.ANTHROPIC_API_KEY;
|
|
53
|
+
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
|
54
|
+
return { supported: false, platform, reason: "unsupported-auth-layout" };
|
|
55
|
+
}
|
|
56
|
+
return { supported: true, platform, strategy: "api-key-env" };
|
|
57
|
+
}
|
|
58
|
+
function failure(platform, reason) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
platform,
|
|
62
|
+
reason,
|
|
63
|
+
message: formatWorkerConfigIsolationReason(platform, reason),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Create a private, owner-only configuration directory for ONE job attempt.
|
|
68
|
+
*
|
|
69
|
+
* The directory lives under the executor's temporary root — never inside the
|
|
70
|
+
* worktree, which would put executor-owned state into the diff the worker is
|
|
71
|
+
* about to commit and would be destroyed by the worktree's own reset/clean paths.
|
|
72
|
+
*
|
|
73
|
+
* Seeds NOTHING of the operator's Claude configuration. Inventory finding 3
|
|
74
|
+
* established that no trust entry is needed to keep a headless worker from
|
|
75
|
+
* prompting, and finding 4 established that the ANTHROPIC credential travels in
|
|
76
|
+
* the environment. No projected fragment of the operator's configuration is
|
|
77
|
+
* written, so none can get stale or over-grant.
|
|
78
|
+
*
|
|
79
|
+
* The single exception is {@link SeededBridgeCredential} — see that type for why
|
|
80
|
+
* an empty directory is NOT sufficient for the Bridge credential specifically:
|
|
81
|
+
* finding 4 does not extend to `BAPI_API_KEY`, which is denied from the worker
|
|
82
|
+
* environment on purpose, leaving the redirected store as its only route in.
|
|
83
|
+
*/
|
|
84
|
+
export async function establishWorkerConfigIsolation(deps, options = {}) {
|
|
85
|
+
const capability = evaluateWorkerConfigIsolationCapability(deps);
|
|
86
|
+
if (!capability.supported) {
|
|
87
|
+
return failure(capability.platform, capability.reason);
|
|
88
|
+
}
|
|
89
|
+
const platform = capability.platform;
|
|
90
|
+
let parent;
|
|
91
|
+
try {
|
|
92
|
+
parent = deps.tmpdir();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
96
|
+
}
|
|
97
|
+
if (typeof parent !== "string" || parent.length === 0) {
|
|
98
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
99
|
+
}
|
|
100
|
+
// Reject a symlinked or non-directory parent BEFORE creating anything beneath
|
|
101
|
+
// it: `mkdtemp` under a symlink would silently place a credential-adjacent
|
|
102
|
+
// directory wherever the link points.
|
|
103
|
+
try {
|
|
104
|
+
const parentMeta = await deps.lstatPath(parent);
|
|
105
|
+
if (!parentMeta || parentMeta.isSymbolicLink || !parentMeta.isDirectory) {
|
|
106
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
111
|
+
}
|
|
112
|
+
// `mkdtemp` is atomic and collision-free, so two concurrent jobs on the same
|
|
113
|
+
// executor can never be handed the same directory.
|
|
114
|
+
let created;
|
|
115
|
+
try {
|
|
116
|
+
created = await deps.mkdtemp(deps.join(parent, ISOLATION_DIR_PREFIX));
|
|
117
|
+
if (typeof created !== "string" || created.length === 0) {
|
|
118
|
+
return failure(platform, "directory-creation-failed");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The raw error may carry a host path; only the fixed reason escapes.
|
|
123
|
+
return failure(platform, "directory-creation-failed");
|
|
124
|
+
}
|
|
125
|
+
// Verify what we actually got is a real directory and not a link, then narrow
|
|
126
|
+
// it to owner-only. `mkdtemp` already creates at 0700 on POSIX; the explicit
|
|
127
|
+
// chmod makes the guarantee independent of the platform's umask behavior.
|
|
128
|
+
try {
|
|
129
|
+
const meta = await deps.lstatPath(created);
|
|
130
|
+
if (!meta || meta.isSymbolicLink || !meta.isDirectory) {
|
|
131
|
+
await safeDispose(deps, created);
|
|
132
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
133
|
+
}
|
|
134
|
+
await deps.chmod(created, ISOLATION_DIR_MODE);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
await safeDispose(deps, created);
|
|
138
|
+
return failure(platform, "directory-creation-failed");
|
|
139
|
+
}
|
|
140
|
+
// The isolated XDG root lives INSIDE the disposable directory, so a single
|
|
141
|
+
// recursive removal reclaims both and no second lifetime needs tracking.
|
|
142
|
+
const xdgConfigHome = deps.join(created, "xdg");
|
|
143
|
+
try {
|
|
144
|
+
await deps.mkdir(xdgConfigHome, { recursive: true });
|
|
145
|
+
await deps.chmod(xdgConfigHome, ISOLATION_DIR_MODE);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
await safeDispose(deps, created);
|
|
149
|
+
return failure(platform, "directory-creation-failed");
|
|
150
|
+
}
|
|
151
|
+
// Seed the one credential the redirect would otherwise strip. Fail-CLOSED,
|
|
152
|
+
// matching this module's posture: a worker that cannot reach its Bridge MCP
|
|
153
|
+
// tools cannot do the job it was dispatched for, and the failure mode is a
|
|
154
|
+
// clean exit that looks like success. Refusing to spawn is strictly better
|
|
155
|
+
// than spawning a worker guaranteed to accomplish nothing.
|
|
156
|
+
const credential = options.bridgeCredential;
|
|
157
|
+
if (credential) {
|
|
158
|
+
if (!deps.writeFile) {
|
|
159
|
+
await safeDispose(deps, created);
|
|
160
|
+
return failure(platform, "missing-filesystem-dependency");
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const bridgeDir = deps.join(xdgConfigHome, "bridge");
|
|
164
|
+
await deps.mkdir(bridgeDir, { recursive: true });
|
|
165
|
+
await deps.chmod(bridgeDir, ISOLATION_DIR_MODE);
|
|
166
|
+
const storePath = deps.join(bridgeDir, "credentials.json");
|
|
167
|
+
await deps.writeFile(storePath,
|
|
168
|
+
// Exactly the shape resolveCredentialBundle reads: a logical target
|
|
169
|
+
// mapping to its named secret values.
|
|
170
|
+
`${JSON.stringify({ [`bapi:${credential.repoName}`]: { BAPI_API_KEY: credential.apiKey } }, null, 2)}\n`);
|
|
171
|
+
// Narrow AFTER writing: the content is secret, and a permissive window
|
|
172
|
+
// between create and chmod is the whole risk being closed here.
|
|
173
|
+
await deps.chmod(storePath, ISOLATION_FILE_MODE);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// The raw error may carry a host path or the secret; only the fixed
|
|
177
|
+
// reason escapes.
|
|
178
|
+
await safeDispose(deps, created);
|
|
179
|
+
return failure(platform, "directory-creation-failed");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let disposed = false;
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
isolation: {
|
|
186
|
+
claudeConfigDir: created,
|
|
187
|
+
xdgConfigHome,
|
|
188
|
+
async dispose() {
|
|
189
|
+
if (disposed)
|
|
190
|
+
return null;
|
|
191
|
+
disposed = true;
|
|
192
|
+
try {
|
|
193
|
+
await deps.rmRecursive(created);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Best-effort by contract: cleanup failure must never replace an
|
|
198
|
+
// already-determined job outcome, and the raw error never escapes.
|
|
199
|
+
return "worker config isolation directory could not be removed";
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Remove a partially-created directory, swallowing every failure. */
|
|
206
|
+
async function safeDispose(deps, path) {
|
|
207
|
+
try {
|
|
208
|
+
await deps.rmRecursive(path);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* best-effort */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Remove stale isolation directories left behind by an ungraceful executor exit
|
|
216
|
+
* (SIGKILL, OOM, host reboot) where per-job disposal never ran.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately conservative, because this function deletes directories: it looks
|
|
219
|
+
* only inside the executor's own temporary root, recognizes only entries
|
|
220
|
+
* carrying {@link ISOLATION_DIR_PREFIX}, requires NON-following metadata to say
|
|
221
|
+
* the entry is a real directory (so a symlink is skipped rather than followed),
|
|
222
|
+
* and requires an age beyond {@link ISOLATION_ORPHAN_MAX_AGE_MS} so a live
|
|
223
|
+
* concurrent executor's active job directory is never removed.
|
|
224
|
+
*
|
|
225
|
+
* Never throws: startup must proceed even if the sweep cannot run at all.
|
|
226
|
+
*/
|
|
227
|
+
export async function sweepOrphanedWorkerConfigDirectories(deps) {
|
|
228
|
+
const result = { removed: 0, skipped: 0, diagnostics: [] };
|
|
229
|
+
const maxAgeMs = typeof deps.maxAgeMs === "number" ? deps.maxAgeMs : ISOLATION_ORPHAN_MAX_AGE_MS;
|
|
230
|
+
let parent;
|
|
231
|
+
try {
|
|
232
|
+
parent = deps.tmpdir();
|
|
233
|
+
if (typeof parent !== "string" || parent.length === 0) {
|
|
234
|
+
result.diagnostics.push("worker config isolation sweep skipped: no temporary root");
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
result.diagnostics.push("worker config isolation sweep skipped: no temporary root");
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
let entries;
|
|
243
|
+
try {
|
|
244
|
+
entries = await deps.readdir(parent);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
result.diagnostics.push("worker config isolation sweep skipped: temporary root unreadable");
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
const cutoff = deps.now() - maxAgeMs;
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
// Prefix match FIRST: anything the module did not create is not ours to
|
|
253
|
+
// delete, whatever its age.
|
|
254
|
+
if (typeof entry !== "string" || !entry.startsWith(ISOLATION_DIR_PREFIX)) {
|
|
255
|
+
result.skipped += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const candidate = deps.join(parent, entry);
|
|
259
|
+
let meta;
|
|
260
|
+
try {
|
|
261
|
+
meta = await deps.lstatPath(candidate);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
result.skipped += 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// A symlink is skipped, never followed — removing it recursively could
|
|
268
|
+
// delete whatever it points at.
|
|
269
|
+
if (!meta || meta.isSymbolicLink || !meta.isDirectory) {
|
|
270
|
+
result.skipped += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!(meta.mtimeMs < cutoff)) {
|
|
274
|
+
result.skipped += 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await deps.rmRecursive(candidate);
|
|
279
|
+
result.removed += 1;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
result.skipped += 1;
|
|
283
|
+
result.diagnostics.push("worker config isolation sweep could not remove a stale directory");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
@@ -22,8 +22,20 @@
|
|
|
22
22
|
* an existence-only check. The check instead compares the authoritative origin
|
|
23
23
|
* branch tip SHA against the worker's own HEAD commit (`headSha`, from git
|
|
24
24
|
* telemetry) whenever that comparison is available.
|
|
25
|
+
*
|
|
26
|
+
* BAPI-762: the authoritative origin-tip-vs-HEAD comparison runs regardless of
|
|
27
|
+
* whether the completion result carries a `pr_url`. The pipeline guarantees a PR
|
|
28
|
+
* URL for essentially every implement job, so a PR-presence short-circuit ABOVE
|
|
29
|
+
* this comparison could never catch an unpushed correction commit — a run could
|
|
30
|
+
* report `clean_exit`/`succeeded` with a PR open and a commit that never reached
|
|
31
|
+
* origin. When the origin tip exists but does not match the worker's own HEAD,
|
|
32
|
+
* that is positive proof of a DIFFERENT failure shape than "no evidence any work
|
|
33
|
+
* reached origin": `WorkerFinalizationSavedButUnfinalized` names it distinctly so
|
|
34
|
+
* the terminal-disposition classifier (`terminal_disposition.py`) can route it as
|
|
35
|
+
* a resumable, deterministic cause instead of laundering it into a generic crash
|
|
36
|
+
* that gets retried as a transient upstream failure.
|
|
25
37
|
*/
|
|
26
|
-
import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, } from "./job-errors.js";
|
|
38
|
+
import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
|
|
27
39
|
import { isImplementationStyleJobType } from "./job-types.js";
|
|
28
40
|
/** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
|
|
29
41
|
const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
|
|
@@ -171,18 +183,51 @@ function prBaseMismatchFailure(job, prNumber, expectedBase, actualBase) {
|
|
|
171
183
|
classification: "crashed",
|
|
172
184
|
};
|
|
173
185
|
}
|
|
174
|
-
|
|
186
|
+
/**
|
|
187
|
+
* `detail` describes a state with NO evidence any work reached origin (branch
|
|
188
|
+
* unresolved, branch absent from origin, or the origin lookup itself failed).
|
|
189
|
+
* `hasPrUrl` only changes the wording — a PR existing is never treated as
|
|
190
|
+
* evidence of a push, so the message must not claim "no PR URL" when one was
|
|
191
|
+
* actually present (that would be false and would mislead the operator reading
|
|
192
|
+
* it toward the wrong recovery step).
|
|
193
|
+
*/
|
|
194
|
+
function missingBranchAndPrFailure(job, detail, hasPrUrl) {
|
|
175
195
|
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
196
|
+
const prefix = hasPrUrl ? "opened a PR, but" : "produced no PR URL and";
|
|
176
197
|
return {
|
|
177
198
|
error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
|
|
178
|
-
error_message: `${label} exited cleanly but ${detail}`,
|
|
199
|
+
error_message: `${label} exited cleanly but ${prefix} ${detail}`,
|
|
200
|
+
classification: "crashed",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* BAPI-762: the origin branch exists at an authoritative tip that does not match
|
|
205
|
+
* the worker's own HEAD — durable work exists on origin, but not this worker's
|
|
206
|
+
* latest commit. Unlike {@link missingBranchAndPrFailure}, this state has positive
|
|
207
|
+
* evidence of something recoverable, so the message names the branch and the
|
|
208
|
+
* origin SHA an operator can resume/finalize from rather than re-running. The
|
|
209
|
+
* human-facing text truncates SHAs to 12 characters (matching the codebase's
|
|
210
|
+
* existing convention); the full 40-character origin SHA is carried in the
|
|
211
|
+
* structured `last_commit_sha` field so nothing has to re-derive it from prose.
|
|
212
|
+
*/
|
|
213
|
+
function savedButUnfinalizedFailure(job, branch, originSha, workerHeadSha) {
|
|
214
|
+
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
215
|
+
return {
|
|
216
|
+
error_kind: WorkerFinalizationSavedButUnfinalized,
|
|
217
|
+
error_message: `${label} exited cleanly, but origin branch '${branch}' is at ${originSha.slice(0, 12)}, ` +
|
|
218
|
+
`which does not match this worker's HEAD ${workerHeadSha.slice(0, 12)} — the worker's latest ` +
|
|
219
|
+
`commit was not pushed. Work already on origin at this SHA is recoverable: resume/finalize ` +
|
|
220
|
+
`from '${branch}' instead of re-running from scratch.`,
|
|
179
221
|
classification: "crashed",
|
|
222
|
+
last_commit_sha: originSha,
|
|
180
223
|
};
|
|
181
224
|
}
|
|
182
225
|
/**
|
|
183
|
-
* Validate that an implementation-style job's clean exit actually
|
|
184
|
-
*
|
|
185
|
-
*
|
|
226
|
+
* Validate that an implementation-style job's clean exit actually published its
|
|
227
|
+
* work to origin. Non-implementation-style jobs (verdict jobs) bypass the git
|
|
228
|
+
* check entirely. A `pr_url` on the completion result is NOT proof of a push
|
|
229
|
+
* (BAPI-762) — the authoritative origin-tip-vs-HEAD comparison below always runs,
|
|
230
|
+
* so an unpushed local commit is detected whether or not a pull request exists.
|
|
186
231
|
*/
|
|
187
232
|
export async function validateWorkerFinalization(input) {
|
|
188
233
|
const { job, branch, worktreePath, result, runCommand, headSha } = input;
|
|
@@ -216,14 +261,15 @@ export async function validateWorkerFinalization(input) {
|
|
|
216
261
|
}
|
|
217
262
|
}
|
|
218
263
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
264
|
+
const hasPrUrl = Boolean(extractPrUrl(result));
|
|
265
|
+
// BAPI-762: `branch`/`headSha` are normalized once here and reused through the
|
|
266
|
+
// rest of the check — including by the diagnostics below — instead of being
|
|
267
|
+
// re-derived at each failure site.
|
|
222
268
|
const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
|
|
223
269
|
if (!trimmedBranch) {
|
|
224
270
|
return {
|
|
225
271
|
ok: false,
|
|
226
|
-
failure: missingBranchAndPrFailure(job, "
|
|
272
|
+
failure: missingBranchAndPrFailure(job, "no expected branch to verify on origin", hasPrUrl),
|
|
227
273
|
};
|
|
228
274
|
}
|
|
229
275
|
// Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
|
|
@@ -233,7 +279,9 @@ export async function validateWorkerFinalization(input) {
|
|
|
233
279
|
// fails loud instead of passing. When HEAD is unresolved (degraded telemetry),
|
|
234
280
|
// fall back to the existence-only check rather than inventing a new failure
|
|
235
281
|
// mode. The authoritative lookup is retried a few times so a push that settles
|
|
236
|
-
// just after the worker exits still finalizes (BAPI-566 Bug A).
|
|
282
|
+
// just after the worker exits still finalizes (BAPI-566 Bug A). This lookup
|
|
283
|
+
// runs unconditionally — including when `hasPrUrl` is true (BAPI-762, AC-5): a
|
|
284
|
+
// PR being open is never accepted as proof this worker's own commit landed.
|
|
237
285
|
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
|
|
238
286
|
const attempts = normalizeAttempts(input.originResolveAttempts);
|
|
239
287
|
const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
|
|
@@ -245,19 +293,25 @@ export async function validateWorkerFinalization(input) {
|
|
|
245
293
|
catch (err) {
|
|
246
294
|
return {
|
|
247
295
|
ok: false,
|
|
248
|
-
failure: missingBranchAndPrFailure(job, `
|
|
296
|
+
failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`, hasPrUrl),
|
|
249
297
|
};
|
|
250
298
|
}
|
|
251
299
|
if (remoteSha === null) {
|
|
252
300
|
return {
|
|
253
301
|
ok: false,
|
|
254
|
-
failure: missingBranchAndPrFailure(job, `
|
|
302
|
+
failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' is not on origin`, hasPrUrl),
|
|
255
303
|
};
|
|
256
304
|
}
|
|
305
|
+
// BAPI-762: an authoritative origin tip that does not match the worker's HEAD
|
|
306
|
+
// is positive evidence of RECOVERABLE work — distinct from the sub-cases above,
|
|
307
|
+
// which have no evidence any work reached origin at all. This is the only
|
|
308
|
+
// sub-case that produces `WorkerFinalizationSavedButUnfinalized`, and it fires
|
|
309
|
+
// identically whether or not a PR is open (AC-5) — the PR shortcut this ticket
|
|
310
|
+
// removes could otherwise conceal exactly this state.
|
|
257
311
|
if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
|
|
258
312
|
return {
|
|
259
313
|
ok: false,
|
|
260
|
-
failure:
|
|
314
|
+
failure: savedButUnfinalizedFailure(job, trimmedBranch, remoteSha, trimmedHeadSha),
|
|
261
315
|
};
|
|
262
316
|
}
|
|
263
317
|
return { ok: true };
|
|
@@ -34,6 +34,10 @@ function toWorktreeCoreDeps(deps) {
|
|
|
34
34
|
platform: deps.platform,
|
|
35
35
|
env: deps.env,
|
|
36
36
|
cwd: deps.cwd,
|
|
37
|
+
// BAPI-727: route path-only untracked-cleanup diagnostics to the executor's
|
|
38
|
+
// stderr logger. Shared `start-tickets` callers may keep omitting this — the
|
|
39
|
+
// primitive treats an absent callback as "do not report".
|
|
40
|
+
onCleanupDiagnostic: deps.errorLog,
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
/** Build the command-provisioning boundary from the executor deps (BAPI-664). */
|
|
@@ -57,12 +61,35 @@ function toCommandProvisioningDeps(deps) {
|
|
|
57
61
|
* preserved unchanged. Errors stay secret-free (no raw filesystem exception, Git
|
|
58
62
|
* output, or command contents).
|
|
59
63
|
*/
|
|
60
|
-
async function finalizeExecutorWorktree(worktreePath, branch, deps) {
|
|
64
|
+
async function finalizeExecutorWorktree(worktreePath, branch, expectedHeadSha, deps) {
|
|
61
65
|
const provisioned = await provisionCommandsForWorktree(worktreePath, toCommandProvisioningDeps(deps));
|
|
62
66
|
if (!provisioned.ok) {
|
|
63
67
|
return { ok: false, error: provisioned.error };
|
|
64
68
|
}
|
|
65
|
-
return { ok: true, worktreePath, branch };
|
|
69
|
+
return { ok: true, worktreePath, branch, expectedHeadSha };
|
|
70
|
+
}
|
|
71
|
+
/** A full 40-character hex object name. */
|
|
72
|
+
const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a ref to its immutable commit via `rev-parse <ref>^{commit}`.
|
|
75
|
+
*
|
|
76
|
+
* Returns `null` on any failure or malformed output. The caller turns that into
|
|
77
|
+
* a bounded worktree failure rather than inventing an expected SHA — a made-up
|
|
78
|
+
* expectation would make the pre-spawn assertion either vacuous or wrong.
|
|
79
|
+
*/
|
|
80
|
+
async function resolveCommitSha(deps, worktreeCwd, ref) {
|
|
81
|
+
try {
|
|
82
|
+
const result = await deps.runCommand("git", ["rev-parse", `${ref}^{commit}`], {
|
|
83
|
+
cwd: worktreeCwd,
|
|
84
|
+
});
|
|
85
|
+
if (!commandSucceeded(result))
|
|
86
|
+
return null;
|
|
87
|
+
const sha = result.stdout.trim();
|
|
88
|
+
return FULL_SHA_PATTERN.test(sha) ? sha : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
66
93
|
}
|
|
67
94
|
/**
|
|
68
95
|
* Ensure the worktree for a job exists, delegating to the shared Worktrunk
|
|
@@ -115,13 +142,25 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
|
|
|
115
142
|
error: `git fetch origin ${branch} failed${reason ? `: ${reason}` : ""}`,
|
|
116
143
|
};
|
|
117
144
|
}
|
|
145
|
+
// BAPI-731: resolve the freshly fetched remote head to an immutable commit
|
|
146
|
+
// BEFORE creating the worktree, so the pre-spawn assertion has an
|
|
147
|
+
// authoritative expectation. Resolved here (right after the fetch) rather
|
|
148
|
+
// than after creation, so the expectation is independent of the worktree the
|
|
149
|
+
// assertion will later inspect.
|
|
150
|
+
const expectedHeadSha = await resolveCommitSha(deps, deps.cwd, originRef);
|
|
151
|
+
if (expectedHeadSha === null) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: `failed to resolve '${originRef}' to a commit after fetching branch '${branch}'`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
118
157
|
const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree,
|
|
119
158
|
// Hard-reset a reused existing local branch onto the freshly fetched
|
|
120
159
|
// pushed head; a missing local branch is instead cut from `baseStartPoint`
|
|
121
160
|
// (= `origin/<branch>`) inside the primitive, so both land at origin.
|
|
122
161
|
{ freshenFromOrigin: originRef });
|
|
123
162
|
if (row.status === "created" && typeof row.path === "string") {
|
|
124
|
-
return finalizeExecutorWorktree(row.path, branch, deps);
|
|
163
|
+
return finalizeExecutorWorktree(row.path, branch, expectedHeadSha, deps);
|
|
125
164
|
}
|
|
126
165
|
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
127
166
|
}
|
|
@@ -146,7 +185,10 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
|
|
|
146
185
|
const baseSha = resolvedBase.base_sha;
|
|
147
186
|
const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, { alignExistingBranchTo: baseSha, verifyHeadMatches: baseSha });
|
|
148
187
|
if (row.status === "created" && typeof row.path === "string") {
|
|
149
|
-
|
|
188
|
+
// BAPI-731: `baseSha` is already the pinned immutable commit this fresh
|
|
189
|
+
// worktree was cut from and verified against (`verifyHeadMatches`), so it is
|
|
190
|
+
// the expectation — no extra resolution needed.
|
|
191
|
+
return finalizeExecutorWorktree(row.path, branch, baseSha, deps);
|
|
150
192
|
}
|
|
151
193
|
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
152
194
|
}
|