@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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.
Files changed (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -132,3 +132,145 @@ export function commitResidue(telemetry) {
132
132
  }
133
133
  return out;
134
134
  }
135
+ // ---------------------------------------------------------------------------
136
+ // BAPI-1296 — the CI-only fixed-path result reader.
137
+ //
138
+ // A `ci_fix` worker writes `<worktree>/.conductor/result.json` with its per-check
139
+ // mapping (and, optionally, an external-attribution diagnosis). Before this
140
+ // ticket the executor never read that file for `ci_fix`, so the mapping the
141
+ // server's exit contract compares against never arrived and every clean exit
142
+ // looked incomplete. This reader transports it — bounded, allowlisted, and
143
+ // read only after THIS attempt's worker exited (the locked pre-spawn cleanup in
144
+ // `job-runner.ts` removed any earlier attempt's file) — and nothing else.
145
+ //
146
+ // It is TELEMETRY transport, not certification: the executor never decides
147
+ // that coverage is complete because the process exited cleanly, never lets the
148
+ // file supply branch/head/PR identifiers, and never lets it name a transition.
149
+ // The server re-derives completeness from the job row's dispatched set.
150
+ // ---------------------------------------------------------------------------
151
+ /** Largest `ci_fix` result artifact read, in bytes. */
152
+ export const CI_FIX_RESULT_MAX_BYTES = 16_384;
153
+ /** Most entries in `check_fix_claims` (mirrors the dispatch cap). */
154
+ export const CI_FIX_CLAIMS_MAX_ENTRIES = 32;
155
+ /** Longest single claim value, in UTF-8 bytes. */
156
+ export const CI_FIX_CLAIM_MAX_BYTES = 512;
157
+ /** Longest attribution reason, in UTF-8 bytes. */
158
+ export const CI_FIX_ATTRIBUTION_REASON_MAX_BYTES = 300;
159
+ /** The per-check mapping key (Python `CI_FIX_CHECK_CLAIMS_KEY`). */
160
+ export const CI_FIX_CHECK_CLAIMS_KEY = "check_fix_claims";
161
+ /** The attribution keys and sentinel (Python `CI_FIX_ATTRIBUTION_*`). */
162
+ export const CI_FIX_ATTRIBUTION_KEY = "ci_failure_attribution";
163
+ export const CI_FIX_ATTRIBUTION_REASON_KEY = "ci_failure_attribution_reason";
164
+ export const CI_FIX_ATTRIBUTION_EXTERNAL = "external";
165
+ /**
166
+ * Executor-owned status of the artifact, carried at `result.check_fix_claims_status`
167
+ * so an operator can tell "the worker wrote nothing" from "the worker wrote
168
+ * something unusable" from "the worker covered only part of the set". It is the
169
+ * executor's own observation, never a worker-supplied value.
170
+ */
171
+ export const CI_FIX_CLAIMS_STATUS_KEY = "check_fix_claims_status";
172
+ const CI_FIX_RESULT_ALLOWED_KEYS = new Set([
173
+ CI_FIX_CHECK_CLAIMS_KEY,
174
+ CI_FIX_ATTRIBUTION_KEY,
175
+ CI_FIX_ATTRIBUTION_REASON_KEY,
176
+ ]);
177
+ function isPlainRecord(value) {
178
+ return value !== null && typeof value === "object" && !Array.isArray(value);
179
+ }
180
+ /**
181
+ * Read and validate `<worktree>/.conductor/result.json` for a `ci_fix` job.
182
+ *
183
+ * `requiredChecks` is the executor-validated dispatched union. A claim for any
184
+ * name outside it, an extra top-level key, an oversized/blank value, an unpaired
185
+ * attribution, a non-regular file, a file over {@link CI_FIX_RESULT_MAX_BYTES},
186
+ * or invalid JSON makes the whole artifact `invalid` — nothing from it is
187
+ * forwarded. A valid artifact that covers only part of the set is `partial` and
188
+ * IS forwarded, so the server's contract check sees exactly what was claimed.
189
+ * Never throws; `code` is a fixed, secret-free token.
190
+ */
191
+ export async function readCiFixResultArtifact(worktreePath, requiredChecks, deps) {
192
+ const artifactPath = path.join(worktreePath, ".conductor", "result.json");
193
+ if (deps.lstatPath) {
194
+ let meta;
195
+ try {
196
+ meta = await deps.lstatPath(artifactPath);
197
+ }
198
+ catch {
199
+ return { status: "invalid", code: "artifact_unreadable" };
200
+ }
201
+ if (meta === null)
202
+ return { status: "missing", code: "artifact_absent" };
203
+ if (meta.isDirectory || meta.isSymbolicLink) {
204
+ return { status: "invalid", code: "artifact_not_regular_file" };
205
+ }
206
+ }
207
+ let raw;
208
+ try {
209
+ raw = await deps.readFile(artifactPath);
210
+ }
211
+ catch (err) {
212
+ const code = err && typeof err === "object" ? err.code : undefined;
213
+ return code === "ENOENT"
214
+ ? { status: "missing", code: "artifact_absent" }
215
+ : { status: "invalid", code: "artifact_unreadable" };
216
+ }
217
+ if (typeof raw !== "string")
218
+ return { status: "invalid", code: "artifact_unreadable" };
219
+ if (byteLength(raw) > CI_FIX_RESULT_MAX_BYTES) {
220
+ return { status: "invalid", code: "artifact_oversized" };
221
+ }
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(raw);
225
+ }
226
+ catch {
227
+ return { status: "invalid", code: "artifact_not_json" };
228
+ }
229
+ if (!isPlainRecord(parsed))
230
+ return { status: "invalid", code: "artifact_not_object" };
231
+ for (const key of Object.keys(parsed)) {
232
+ if (!CI_FIX_RESULT_ALLOWED_KEYS.has(key)) {
233
+ return { status: "invalid", code: "artifact_unexpected_key" };
234
+ }
235
+ }
236
+ const claims = parsed[CI_FIX_CHECK_CLAIMS_KEY];
237
+ if (!isPlainRecord(claims))
238
+ return { status: "invalid", code: "claims_not_object" };
239
+ const claimNames = Object.keys(claims);
240
+ if (claimNames.length > CI_FIX_CLAIMS_MAX_ENTRIES) {
241
+ return { status: "invalid", code: "claims_too_many" };
242
+ }
243
+ const required = new Set(requiredChecks);
244
+ const validatedClaims = {};
245
+ for (const name of claimNames) {
246
+ const value = claims[name];
247
+ if (!required.has(name))
248
+ return { status: "invalid", code: "claims_undispatched_check" };
249
+ if (typeof value !== "string" ||
250
+ value.trim().length === 0 ||
251
+ byteLength(value) > CI_FIX_CLAIM_MAX_BYTES) {
252
+ return { status: "invalid", code: "claims_value_invalid" };
253
+ }
254
+ validatedClaims[name] = value;
255
+ }
256
+ const fields = { [CI_FIX_CHECK_CLAIMS_KEY]: validatedClaims };
257
+ const hasAttribution = Object.prototype.hasOwnProperty.call(parsed, CI_FIX_ATTRIBUTION_KEY);
258
+ const hasReason = Object.prototype.hasOwnProperty.call(parsed, CI_FIX_ATTRIBUTION_REASON_KEY);
259
+ if (hasAttribution || hasReason) {
260
+ const attribution = parsed[CI_FIX_ATTRIBUTION_KEY];
261
+ const reason = parsed[CI_FIX_ATTRIBUTION_REASON_KEY];
262
+ if (!hasAttribution ||
263
+ !hasReason ||
264
+ typeof attribution !== "string" ||
265
+ attribution.trim().toLowerCase() !== CI_FIX_ATTRIBUTION_EXTERNAL ||
266
+ typeof reason !== "string" ||
267
+ reason.trim().length === 0 ||
268
+ byteLength(reason) > CI_FIX_ATTRIBUTION_REASON_MAX_BYTES) {
269
+ return { status: "invalid", code: "attribution_invalid" };
270
+ }
271
+ fields[CI_FIX_ATTRIBUTION_KEY] = CI_FIX_ATTRIBUTION_EXTERNAL;
272
+ fields[CI_FIX_ATTRIBUTION_REASON_KEY] = reason;
273
+ }
274
+ const covered = requiredChecks.every((name) => Object.prototype.hasOwnProperty.call(validatedClaims, name));
275
+ return { status: covered ? "complete" : "partial", fields };
276
+ }
@@ -14,7 +14,7 @@
14
14
  * §16 — the amended proposal explicitly rejected `fetch && reset --hard`).
15
15
  */
16
16
  import { createWorktreeForTicket } from "../worktree-core.js";
17
- import { ExecutorNamedError, WorktreeLostBeforePush } from "./job-errors.js";
17
+ import { ContinuationLineageMismatch, ContinuationWorktreeMissing, ExecutorNamedError, WorktreeLostBeforePush, } from "./job-errors.js";
18
18
  import { assertWorktreeOnBranch, listGitWorktrees, findWorktreeByBranch, remoteBranchExists as remoteBranchExistsDefault, } from "./worktree-inspection.js";
19
19
  /** The canonical WIP checkpoint commit message (executor-run, deterministic). */
20
20
  export const RESUME_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before resume";
@@ -119,13 +119,13 @@ export async function recreateResumeWorktreeFromPushedBranch(job, expectedBranch
119
119
  * is always clean and complete. No-op cleanly when the tree is already clean.
120
120
  * NEVER discards work.
121
121
  */
122
- export async function autoCheckpointDirtyState(worktreePath, seams) {
122
+ export async function autoCheckpointDirtyState(worktreePath, seams, message = RESUME_WIP_COMMIT_MESSAGE) {
123
123
  const status = await seams.runCommand("git", ["status", "--porcelain"], { cwd: worktreePath });
124
124
  if (status.stdout.trim().length === 0) {
125
125
  return { committed: false };
126
126
  }
127
127
  await seams.runCommand("git", ["add", "-A"], { cwd: worktreePath });
128
- await seams.runCommand("git", ["commit", "-m", RESUME_WIP_COMMIT_MESSAGE], {
128
+ await seams.runCommand("git", ["commit", "-m", message], {
129
129
  cwd: worktreePath,
130
130
  });
131
131
  return { committed: true };
@@ -144,6 +144,75 @@ export async function readResumeGitDiff(worktreePath, baseBranch, seams) {
144
144
  export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
145
145
  return template.split("{{GIT_LOG}}").join(gitLog).split("{{GIT_DIFF}}").join(gitDiff);
146
146
  }
147
+ // ---------------------------------------------------------------------------
148
+ // BAPI-1300 — parked-child continuation: retained-work verification
149
+ // ---------------------------------------------------------------------------
150
+ /** The deterministic WIP checkpoint message for a continuation. */
151
+ export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
152
+ const FULL_SHA = /^[0-9a-f]{40}$/;
153
+ /** Read the continuation binding from a claimed job's payload (pure). */
154
+ export function readContinuationBinding(job) {
155
+ const payload = job.payload && typeof job.payload === "object" ? job.payload : null;
156
+ const raw = payload?.continuation;
157
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
158
+ return null;
159
+ const block = raw;
160
+ if (block.retained_worktree !== true)
161
+ return null;
162
+ const receiptId = asString(block.receipt_id);
163
+ if (!receiptId)
164
+ return null;
165
+ const head = asString(block.bound_head_sha)?.toLowerCase() ?? null;
166
+ return {
167
+ receiptId,
168
+ branch: asString(block.branch) ?? null,
169
+ boundHeadSha: head !== null && FULL_SHA.test(head) ? head : null,
170
+ };
171
+ }
172
+ /**
173
+ * Prove the retained worktree's HEAD descends from the authorized head.
174
+ *
175
+ * Run BEFORE the WIP checkpoint: a checkpoint commits onto whatever HEAD is, so
176
+ * checking afterwards would already have written onto an unrelated history. A
177
+ * HEAD that equals the bound head, or has it as an ancestor (the worker's own
178
+ * later local commits), is accepted; anything else — including a bound head the
179
+ * worktree does not contain at all — is refused. No bound head (nothing was ever
180
+ * pushed) leaves only the branch check, which the caller has already made.
181
+ */
182
+ export async function verifyContinuationHeadLineage(worktreePath, boundHeadSha, seams) {
183
+ if (boundHeadSha === null)
184
+ return;
185
+ const result = await seams.runCommand("git", ["merge-base", "--is-ancestor", boundHeadSha, "HEAD"], { cwd: worktreePath });
186
+ if (result.exitCode !== 0) {
187
+ throw new ExecutorNamedError(ContinuationLineageMismatch, `continuation worktree HEAD does not descend from the authorized head ${boundHeadSha.slice(0, 12)}`);
188
+ }
189
+ }
190
+ /**
191
+ * Locate and verify a continuation's RETAINED worktree, then checkpoint it.
192
+ *
193
+ * Order: resolve the existing worktree (never recreate) -> verify its branch ->
194
+ * verify its head lineage -> WIP-checkpoint dirty state. A missing worktree is a
195
+ * refusal ({@link ContinuationWorktreeMissing}), not a recreation from origin:
196
+ * recreation cannot restore unpushed commits, and continuing without them would
197
+ * silently discard exactly the work the continuation exists to carry. The
198
+ * checkpoint is recorded as lineage of the authorized head; it never adopts an
199
+ * arbitrary head. No destructive Git operation is ever issued.
200
+ */
201
+ export async function prepareRetainedContinuationWorktree(job, binding, seams) {
202
+ const fields = readResumePayloadFields(job);
203
+ const expectedBranch = asString(job.expected_branch) ?? fields.expectedBranch ?? binding.branch ?? undefined;
204
+ if (!expectedBranch) {
205
+ throw new ExecutorNamedError("ResumeMissingExpectedBranch", `continuation job ${job.id} has no expected_branch`);
206
+ }
207
+ const worktreePath = await resolveExistingResumeWorktreePath(fields, expectedBranch, seams);
208
+ if (worktreePath === null) {
209
+ throw new ExecutorNamedError(ContinuationWorktreeMissing, `continuation for '${expectedBranch}' found no retained worktree; it is never recreated from origin because unpushed work cannot be recovered that way`);
210
+ }
211
+ await assertWorktreeOnBranch(seams.runCommand, worktreePath, expectedBranch);
212
+ await verifyContinuationHeadLineage(worktreePath, binding.boundHeadSha, seams);
213
+ const checkpoint = await autoCheckpointDirtyState(worktreePath, seams, CONTINUATION_WIP_COMMIT_MESSAGE);
214
+ return { worktreePath, branch: expectedBranch, checkpointed: checkpoint.committed };
215
+ }
147
216
  /**
148
217
  * Orchestrate the resume pre-spawn protocol in the required order:
149
218
  * 1. read payload fields
@@ -165,14 +234,28 @@ export async function prepareResumeSpawn(job, options, seams) {
165
234
  // Fail loud on a missing/empty prompt template BEFORE any worktree resolution
166
235
  // or WIP-checkpoint commit — never spawn an empty-prompt worker.
167
236
  const promptTemplate = resolveResumePromptTemplate(job, fields);
237
+ // BAPI-1300: a resume dispatched as a parked-child continuation carries a
238
+ // binding and follows the retained-work protocol — never recreated from origin,
239
+ // and its head lineage is proven before the checkpoint. Every other resume job
240
+ // keeps the original protocol below unchanged.
241
+ const continuation = readContinuationBinding(job);
168
242
  let worktreePath = await resolveExistingResumeWorktreePath(fields, expectedBranch, seams);
169
243
  if (worktreePath === null) {
244
+ if (continuation !== null) {
245
+ throw new ExecutorNamedError(ContinuationWorktreeMissing, `continuation for '${expectedBranch}' found no retained worktree; it is never recreated from origin because unpushed work cannot be recovered that way`);
246
+ }
170
247
  worktreePath = await recreateResumeWorktreeFromPushedBranch(job, expectedBranch, seams);
171
248
  }
172
249
  // Branch verification BEFORE any local mutation — never checkpoint/commit on
173
250
  // the wrong branch.
174
251
  await assertWorktreeOnBranch(seams.runCommand, worktreePath, expectedBranch);
175
- await autoCheckpointDirtyState(worktreePath, seams);
252
+ if (continuation !== null) {
253
+ await verifyContinuationHeadLineage(worktreePath, continuation.boundHeadSha, seams);
254
+ await autoCheckpointDirtyState(worktreePath, seams, CONTINUATION_WIP_COMMIT_MESSAGE);
255
+ }
256
+ else {
257
+ await autoCheckpointDirtyState(worktreePath, seams);
258
+ }
176
259
  const gitLog = await readResumeGitLog(worktreePath, baseBranch, seams);
177
260
  const gitDiff = await readResumeGitDiff(worktreePath, baseBranch, seams);
178
261
  const prompt = fillResumePromptPlaceholders(promptTemplate, gitLog, gitDiff);
@@ -10,10 +10,14 @@ import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } f
10
10
  import { runClaimedJob, runIntegrationCorrectionSpawn } from "./job-runner.js";
11
11
  import { appendToActiveJobLogs } from "./job-log-registry.js";
12
12
  import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
13
+ import os from "node:os";
13
14
  import { sweepExecutorWorktrees } from "./worktree-gc.js";
15
+ import { createMacProcessInspector, hostIdentityDigest, recoverInterruptedOwnership, } from "./process-containment.js";
16
+ import { resolveExecutorStateDir } from "./job-log-registry.js";
14
17
  import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
15
18
  import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
16
19
  import { validateBranchName } from "../base-ref.js";
20
+ import { isTerminalEpicRunStatus } from "../epic-run-status.js";
17
21
  import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
18
22
  /**
19
23
  * Signals that request a graceful executor shutdown (BAPI-828).
@@ -96,6 +100,16 @@ export function formatProcessHeartbeatFailureDiagnostic() {
96
100
  export function formatProcessHeartbeatRecoveredDiagnostic() {
97
101
  return "executor: process-heartbeat delivery has recovered.";
98
102
  }
103
+ /**
104
+ * BAPI-1279 — the diagnostic for a failed run-state read under
105
+ * `--exit-when-run-terminal`. Fixed text plus a closed reason token: no body,
106
+ * no URL, no credential. The executor keeps claiming; a failed read never
107
+ * causes an exit.
108
+ */
109
+ export function formatRunTerminalReadFailureDiagnostic(reason) {
110
+ return (`executor: could not read the scoped run's state (reason=${reason}); ` +
111
+ "staying up and claiming until it can be read.");
112
+ }
99
113
  /** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
100
114
  function correctionTimeoutSeconds(deadlineAt, nowMs) {
101
115
  const deadline = Date.parse(deadlineAt);
@@ -103,6 +117,23 @@ function correctionTimeoutSeconds(deadlineAt, nowMs) {
103
117
  return 60;
104
118
  return Math.max(60, Math.floor((deadline - nowMs) / 1000) - 60);
105
119
  }
120
+ /**
121
+ * BAPI-1301: the read/remove-only record filesystem startup recovery uses, built
122
+ * from the executor's injected deps. Without a `readdir` seam there is nothing
123
+ * to list, so recovery is a no-op; recovery never writes a record.
124
+ */
125
+ function recoveryRecordFs(deps) {
126
+ const refuseWrite = async () => {
127
+ throw new Error("startup ownership recovery never writes records");
128
+ };
129
+ return {
130
+ mkdir: refuseWrite,
131
+ writeFile: refuseWrite,
132
+ readFile: (filePath) => deps.readFile(filePath),
133
+ readdir: deps.readdir ? (dirPath) => deps.readdir(dirPath) : async () => [],
134
+ removeFile: deps.removeFile ? (filePath) => deps.removeFile(filePath) : async () => { },
135
+ };
136
+ }
106
137
  /**
107
138
  * Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
108
139
  * in continuous mode it does not return (the process is long-lived).
@@ -120,7 +151,43 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
120
151
  // optional mtime boundary (keeps GC conservative → retains).
121
152
  statMtimeMs: deps.statMtimeMs ?? (async () => null),
122
153
  platform: deps.platform,
154
+ // BAPI-1330: finds the per-attempt worker logs for the activity probe.
155
+ ...(deps.readdir ? { listDir: deps.readdir } : {}),
123
156
  }));
157
+ // BAPI-1301 — before the first claim, inspect ownership records left by a
158
+ // previous executor process on this host. Each record holds the real per-job
159
+ // token, so the sweep can only ever reach processes that job tagged. The
160
+ // SERVER still parks such a job as `cleanup_unconfirmed` (it cannot know this
161
+ // inspection happened); the verdict here is logged for the operator.
162
+ const recoverOwnedProcesses = seams.recoverOwnedProcesses ??
163
+ (async () => {
164
+ const results = await recoverInterruptedOwnership({
165
+ stateDir: resolveExecutorStateDir({ env: deps.env, tmpdir: () => os.tmpdir(), platform: deps.platform }),
166
+ inspector: createMacProcessInspector({
167
+ runCommand: deps.runCommand,
168
+ selfPid: process.pid,
169
+ platform: deps.platform,
170
+ }),
171
+ deps: { now: deps.now, sleep: deps.sleep, log: deps.errorLog },
172
+ // Recovery only lists, reads and removes records, so it goes through the
173
+ // executor's own filesystem seams: an embedded caller or a test with
174
+ // in-memory deps never touches the real state directory.
175
+ fsDeps: recoveryRecordFs(deps),
176
+ deadlineMs: 30_000,
177
+ hostDigest: hostIdentityDigest(os.hostname()),
178
+ selfPid: process.pid,
179
+ });
180
+ for (const r of results) {
181
+ deps.errorLog(`[executor] startup ownership inspection: job ${r.jobId} cleanup ${r.verdict.verdict} ` +
182
+ `(survivors=${r.verdict.survivors})`);
183
+ }
184
+ });
185
+ try {
186
+ await recoverOwnedProcesses();
187
+ }
188
+ catch (err) {
189
+ deps.errorLog(`[executor] startup ownership inspection failed (non-fatal): ${err instanceof Error ? err.message.slice(0, 200) : "unknown error"}`);
190
+ }
124
191
  const active = new Map();
125
192
  // BAPI-1259 — integration actions share `active` (and therefore the same
126
193
  // capacity budget and shutdown drain) with ordinary jobs, keyed by negative
@@ -293,6 +360,50 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
293
360
  deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
294
361
  }
295
362
  };
363
+ // BAPI-1279 — `--exit-when-run-terminal`. Only ever true for a process scoped
364
+ // to exactly one run; the CLI refuses the flag otherwise, and this re-check
365
+ // keeps an embedded caller from arming it on an ambiguous scope.
366
+ const exitWhenRunTerminal = options.exitWhenRunTerminal === true && observedEpicRunId !== null;
367
+ let runTerminal = false;
368
+ // The last failure reason reported, so a long outage logs once per change
369
+ // rather than once per poll.
370
+ let lastRunStateFailure = null;
371
+ /**
372
+ * Is the scoped run finished? Called only between claim cycles while this
373
+ * process holds NO job. The wind-down observer runs FIRST, so a run that just
374
+ * reached `done` still gets its integration-PR ready request before the
375
+ * executor retires. Any failed read returns `false`: an executor that cannot
376
+ * see its run keeps claiming rather than guessing it is done.
377
+ */
378
+ const scopedRunIsTerminal = async () => {
379
+ if (!exitWhenRunTerminal || observedEpicRunId === null)
380
+ return false;
381
+ await observeEpicRunCompletion();
382
+ const readState = httpClient.readEpicRunState;
383
+ if (typeof readState !== "function")
384
+ return false;
385
+ let reason = null;
386
+ let terminal = false;
387
+ try {
388
+ const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
389
+ if (result.ok)
390
+ terminal = isTerminalEpicRunStatus(result.state.status);
391
+ else
392
+ reason = result.reason;
393
+ }
394
+ catch {
395
+ reason = "unexpected";
396
+ }
397
+ if (reason !== null) {
398
+ if (reason !== lastRunStateFailure) {
399
+ deps.errorLog(formatRunTerminalReadFailureDiagnostic(reason));
400
+ }
401
+ lastRunStateFailure = reason;
402
+ return false;
403
+ }
404
+ lastRunStateFailure = null;
405
+ return terminal;
406
+ };
296
407
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
297
408
  // the claim loop below — that scope is the whole feature. A cache created inside
298
409
  // the loop would be discarded every cycle and re-probe exactly as before; a
@@ -448,6 +559,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
448
559
  * a failure is reported to stderr and the loop keeps going. It cannot fail a
449
560
  * claim, fail a job, or end the run.
450
561
  */
562
+ let latestCorrectionQualification = null;
451
563
  const runProcessHeartbeatLoop = async () => {
452
564
  const publish = httpClient.processHeartbeat;
453
565
  // A client without the capability (every object-literal test fake) simply
@@ -461,11 +573,39 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
461
573
  component: "executor",
462
574
  instance_id: options.executorId,
463
575
  repo_names: repoNames,
576
+ // BAPI-1279 — the claim MODE, so the server can report whether a durable
577
+ // repo-wide executor covers the repository.
578
+ //
579
+ // BAPI-1282 — plus the full run id, but ONLY for an executor scoped to
580
+ // exactly one run. This deliberately reverses BAPI-1279's "never the run
581
+ // ids": the server needs the complete id to check, per repository, that
582
+ // the run this executor served is `done`/`abandoned`, so a cleanly retired
583
+ // executor's stale row stops reading as a lost executor — even when this
584
+ // process never reaches the server again. The id is one bounded, non-secret,
585
+ // server-minted UUID the operator already passed as `--epic-run-id`. The
586
+ // 8-character prefix inside `drive-epic-<runid8>-<host>` is not a substitute:
587
+ // it is ambiguous, and absent for a hand-chosen `--executor-id`. A
588
+ // multi-run or repo-wide executor publishes no binding.
589
+ metadata: options.epicRunIds === undefined
590
+ ? { claim_mode: "repo_wide" }
591
+ : observedEpicRunId !== null
592
+ ? { claim_mode: "scoped", epic_run_id: observedEpicRunId }
593
+ : { claim_mode: "scoped" },
464
594
  };
595
+ const baseMetadata = request.metadata;
465
596
  // Suppression state: one message per CHANGE of delivery state, so a long
466
597
  // outage produces one line rather than one per minute.
467
598
  let lastFailed = false;
468
599
  for (;;) {
600
+ request.metadata = {
601
+ ...baseMetadata,
602
+ ...(latestCorrectionQualification
603
+ ? { correction_intent: latestCorrectionQualification }
604
+ : {}),
605
+ ...(latestCorrectionQualification?.qualified === true
606
+ ? { ci_evidence: { version: 1, qualified: true } }
607
+ : {}),
608
+ };
469
609
  let outcome;
470
610
  try {
471
611
  outcome = await publish.call(httpClient, request);
@@ -654,6 +794,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
654
794
  try {
655
795
  for (;;) {
656
796
  const report = await collectPreflight(options, deps, preflightSeams);
797
+ latestCorrectionQualification = report.correctionIntentQualified === true &&
798
+ report.correctionQualificationEvidence?.hook_installed === true &&
799
+ report.correctionQualificationEvidence.nested_denial === true
800
+ ? {
801
+ version: 1, qualified: true, hook_installed: true,
802
+ nested_denial: true,
803
+ fixture_id: report.correctionQualificationEvidence.fixture_id,
804
+ runtime: report.correctionQualificationEvidence.runtime,
805
+ }
806
+ : null;
657
807
  // First heartbeat goes out immediately once preflight passes, so a freshly
658
808
  // started executor is visible to the server without waiting one interval.
659
809
  // Gated on `report.ok` because an executor that preflight refuses is not
@@ -758,6 +908,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
758
908
  await maybeSweepWorktrees();
759
909
  }
760
910
  }
911
+ // BAPI-1279 — retire once the scoped run is finished and nothing is held.
912
+ // Checked whether or not preflight passed: an executor that preflight
913
+ // keeps refusing still holds a sleep assertion and a record, and must not
914
+ // outlive its run. `break` takes the SAME path a `--once` pass does:
915
+ // drain, stop the heartbeat loop, unsubscribe, exit 0.
916
+ if (!shutdownRequested && active.size === 0 && (await scopedRunIsTerminal())) {
917
+ runTerminal = true;
918
+ deps.errorLog(`executor: run ${observedEpicRunId} is finished and no job is held; exiting.`);
919
+ break;
920
+ }
761
921
  if (options.once || shutdownRequested)
762
922
  break;
763
923
  // Sample the clock immediately around the ACTUAL sleep, so the measured gap
@@ -807,6 +967,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
807
967
  }
808
968
  }
809
969
  }
970
+ if (runTerminal && observedEpicRunId !== null && seams.onRunTerminal) {
971
+ try {
972
+ await seams.onRunTerminal(observedEpicRunId);
973
+ }
974
+ catch {
975
+ /* record cleanup must never change a clean terminal exit */
976
+ }
977
+ }
810
978
  // A graceful signal shutdown is a NORMAL executor exit, not an error: the run did
811
979
  // exactly what it was asked to do. A distinct nonzero code here would make every
812
980
  // deliberate operator stop look like a crash to launchd, systemd, and the CLI
@@ -25,10 +25,11 @@ export const CONDUCTOR_CRITIQUE_FILE = "critique.md";
25
25
  /**
26
26
  * The files a prior attempt may leave behind, and the ONLY ones removed here.
27
27
  *
28
- * `worker.lock` and `worker.log` are deliberately absent: the lock is live
29
- * coordination state, and the log is forensic evidence truncated by its own
30
- * owner (`worker-log.ts`). The `.conductor/` directory itself and any unrelated
31
- * file inside it are left untouched.
28
+ * `worker.lock` and the worker logs are deliberately absent: the lock is live
29
+ * coordination state, and each attempt's log is forensic evidence that its owner
30
+ * (`worker-log.ts`) creates exclusively and never truncates (BAPI-1330). The
31
+ * `.conductor/` directory itself and any unrelated file inside it are left
32
+ * untouched.
32
33
  */
33
34
  export const STALE_ATTEMPT_ARTIFACT_FILES = [
34
35
  CONDUCTOR_RESULT_FILE,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Test support (BAPI-1302): build a required-verification declaration exactly as
3
+ * the server's `declaration_from_plan_node` seals it, so executor tests exercise
4
+ * the real strict reader rather than a hand-written shape.
5
+ */
6
+ import { computeDeclarationDigest, declaredCommandIdentity } from "./completion-supervision.js";
7
+ export function sealDeclaration(commands, provenance = commands.length > 0 ? "declared" : "absent") {
8
+ const body = {
9
+ schema_version: 1,
10
+ provenance,
11
+ plan_version: 7,
12
+ plan_hash: "a".repeat(64),
13
+ commands: commands.map((c, position) => ({
14
+ identity: declaredCommandIdentity(position, c),
15
+ position,
16
+ ...c,
17
+ })),
18
+ };
19
+ return { ...body, digest: computeDeclarationDigest(body) };
20
+ }
@@ -42,6 +42,19 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
42
42
  "no_progress",
43
43
  "lease_expired",
44
44
  ];
45
+ /**
46
+ * BAPI-1311 — the capability an executor declares when it can run a FOCUSED
47
+ * merge-stage `rebase`: validate `payload.focused_rebase` before any worker
48
+ * effect, use the server-rendered prompt verbatim, and supervise the post-push
49
+ * mergeability/required-check wait under the job's own claim and deadline.
50
+ * Mirrors `FOCUSED_REBASE_CAPABILITY` in
51
+ * `src/python/llms/executor_completion_contract.py`. Independent of completion
52
+ * evidence: `rebase` is not a covered job type.
53
+ */
54
+ export const FOCUSED_REBASE_CAPABILITY = "focused_rebase.v1";
55
+ export const SCOPE_WAIT_CAPABILITY = "scope_wait.v1";
56
+ /** The focused context version; the same string as the capability on purpose. */
57
+ export const FOCUSED_REBASE_CONTEXT_VERSION = FOCUSED_REBASE_CAPABILITY;
45
58
  /**
46
59
  * BAPI-1258 — the terminal provider-capacity protocol contract, mirroring
47
60
  * `src/python/llms/executor_capacity_contract.py`. Closed values: an executor