@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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 (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -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);
@@ -7,11 +7,17 @@
7
7
  * after all active jobs finish. Depends on NO v1 conductor event/ledger module.
8
8
  */
9
9
  import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
10
- import { runClaimedJob } from "./job-runner.js";
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";
17
+ import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
18
+ import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
14
19
  import { validateBranchName } from "../base-ref.js";
20
+ import { isTerminalEpicRunStatus } from "../epic-run-status.js";
15
21
  import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
16
22
  /**
17
23
  * Signals that request a graceful executor shutdown (BAPI-828).
@@ -94,6 +100,40 @@ export function formatProcessHeartbeatFailureDiagnostic() {
94
100
  export function formatProcessHeartbeatRecoveredDiagnostic() {
95
101
  return "executor: process-heartbeat delivery has recovered.";
96
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
+ }
113
+ /** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
114
+ function correctionTimeoutSeconds(deadlineAt, nowMs) {
115
+ const deadline = Date.parse(deadlineAt);
116
+ if (!Number.isFinite(deadline))
117
+ return 60;
118
+ return Math.max(60, Math.floor((deadline - nowMs) / 1000) - 60);
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
+ }
97
137
  /**
98
138
  * Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
99
139
  * in continuous mode it does not return (the process is long-lived).
@@ -112,7 +152,48 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
112
152
  statMtimeMs: deps.statMtimeMs ?? (async () => null),
113
153
  platform: deps.platform,
114
154
  }));
155
+ // BAPI-1301 — before the first claim, inspect ownership records left by a
156
+ // previous executor process on this host. Each record holds the real per-job
157
+ // token, so the sweep can only ever reach processes that job tagged. The
158
+ // SERVER still parks such a job as `cleanup_unconfirmed` (it cannot know this
159
+ // inspection happened); the verdict here is logged for the operator.
160
+ const recoverOwnedProcesses = seams.recoverOwnedProcesses ??
161
+ (async () => {
162
+ const results = await recoverInterruptedOwnership({
163
+ stateDir: resolveExecutorStateDir({ env: deps.env, tmpdir: () => os.tmpdir(), platform: deps.platform }),
164
+ inspector: createMacProcessInspector({
165
+ runCommand: deps.runCommand,
166
+ selfPid: process.pid,
167
+ platform: deps.platform,
168
+ }),
169
+ deps: { now: deps.now, sleep: deps.sleep, log: deps.errorLog },
170
+ // Recovery only lists, reads and removes records, so it goes through the
171
+ // executor's own filesystem seams: an embedded caller or a test with
172
+ // in-memory deps never touches the real state directory.
173
+ fsDeps: recoveryRecordFs(deps),
174
+ deadlineMs: 30_000,
175
+ hostDigest: hostIdentityDigest(os.hostname()),
176
+ selfPid: process.pid,
177
+ });
178
+ for (const r of results) {
179
+ deps.errorLog(`[executor] startup ownership inspection: job ${r.jobId} cleanup ${r.verdict.verdict} ` +
180
+ `(survivors=${r.verdict.survivors})`);
181
+ }
182
+ });
183
+ try {
184
+ await recoverOwnedProcesses();
185
+ }
186
+ catch (err) {
187
+ deps.errorLog(`[executor] startup ownership inspection failed (non-fatal): ${err instanceof Error ? err.message.slice(0, 200) : "unknown error"}`);
188
+ }
115
189
  const active = new Map();
190
+ // BAPI-1259 — integration actions share `active` (and therefore the same
191
+ // capacity budget and shutdown drain) with ordinary jobs, keyed by negative
192
+ // numbers so they can never collide with a server job id.
193
+ let integrationSeq = 0;
194
+ // Set once when the server says it has no integration-action route (an older
195
+ // deployment), so the executor stops asking rather than logging every poll.
196
+ let integrationActionsUnsupported = false;
116
197
  // BAPI-794 — process-local cross-run claim observability. Retains only the
117
198
  // last successfully claimed job's `epic_run_id` (never job payloads,
118
199
  // credentials, or arguments) so an unintended repo-wide move between epic
@@ -277,6 +358,50 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
277
358
  deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
278
359
  }
279
360
  };
361
+ // BAPI-1279 — `--exit-when-run-terminal`. Only ever true for a process scoped
362
+ // to exactly one run; the CLI refuses the flag otherwise, and this re-check
363
+ // keeps an embedded caller from arming it on an ambiguous scope.
364
+ const exitWhenRunTerminal = options.exitWhenRunTerminal === true && observedEpicRunId !== null;
365
+ let runTerminal = false;
366
+ // The last failure reason reported, so a long outage logs once per change
367
+ // rather than once per poll.
368
+ let lastRunStateFailure = null;
369
+ /**
370
+ * Is the scoped run finished? Called only between claim cycles while this
371
+ * process holds NO job. The wind-down observer runs FIRST, so a run that just
372
+ * reached `done` still gets its integration-PR ready request before the
373
+ * executor retires. Any failed read returns `false`: an executor that cannot
374
+ * see its run keeps claiming rather than guessing it is done.
375
+ */
376
+ const scopedRunIsTerminal = async () => {
377
+ if (!exitWhenRunTerminal || observedEpicRunId === null)
378
+ return false;
379
+ await observeEpicRunCompletion();
380
+ const readState = httpClient.readEpicRunState;
381
+ if (typeof readState !== "function")
382
+ return false;
383
+ let reason = null;
384
+ let terminal = false;
385
+ try {
386
+ const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
387
+ if (result.ok)
388
+ terminal = isTerminalEpicRunStatus(result.state.status);
389
+ else
390
+ reason = result.reason;
391
+ }
392
+ catch {
393
+ reason = "unexpected";
394
+ }
395
+ if (reason !== null) {
396
+ if (reason !== lastRunStateFailure) {
397
+ deps.errorLog(formatRunTerminalReadFailureDiagnostic(reason));
398
+ }
399
+ lastRunStateFailure = reason;
400
+ return false;
401
+ }
402
+ lastRunStateFailure = null;
403
+ return terminal;
404
+ };
280
405
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
281
406
  // the claim loop below — that scope is the whole feature. A cache created inside
282
407
  // the loop would be discarded every cycle and re-probe exactly as before; a
@@ -445,6 +570,24 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
445
570
  component: "executor",
446
571
  instance_id: options.executorId,
447
572
  repo_names: repoNames,
573
+ // BAPI-1279 — the claim MODE, so the server can report whether a durable
574
+ // repo-wide executor covers the repository.
575
+ //
576
+ // BAPI-1282 — plus the full run id, but ONLY for an executor scoped to
577
+ // exactly one run. This deliberately reverses BAPI-1279's "never the run
578
+ // ids": the server needs the complete id to check, per repository, that
579
+ // the run this executor served is `done`/`abandoned`, so a cleanly retired
580
+ // executor's stale row stops reading as a lost executor — even when this
581
+ // process never reaches the server again. The id is one bounded, non-secret,
582
+ // server-minted UUID the operator already passed as `--epic-run-id`. The
583
+ // 8-character prefix inside `drive-epic-<runid8>-<host>` is not a substitute:
584
+ // it is ambiguous, and absent for a hand-chosen `--executor-id`. A
585
+ // multi-run or repo-wide executor publishes no binding.
586
+ metadata: options.epicRunIds === undefined
587
+ ? { claim_mode: "repo_wide" }
588
+ : observedEpicRunId !== null
589
+ ? { claim_mode: "scoped", epic_run_id: observedEpicRunId }
590
+ : { claim_mode: "scoped" },
448
591
  };
449
592
  // Suppression state: one message per CHANGE of delivery state, so a long
450
593
  // outage produces one line rather than one per minute.
@@ -536,6 +679,85 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
536
679
  })();
537
680
  active.set(job.id, promise);
538
681
  }
682
+ const runOneIntegrationAction = seams.runIntegrationAction ??
683
+ ((action) => runIntegrationAction(action, seams.integrationActions, {
684
+ env: deps.env,
685
+ runCommand: deps.runCommand,
686
+ sleep: deps.sleep,
687
+ errorLog: deps.errorLog,
688
+ cwd: deps.cwd,
689
+ }, {
690
+ // A correction reuses the executor's ONE locked spawn path: the same
691
+ // worktree lock, containment, and supervision every worker gets.
692
+ runCorrection: async (claimed, driver, heartbeat) => {
693
+ const adapter = createIntegrationCorrectionLeaseAdapter(heartbeat);
694
+ const job = buildIntegrationCorrectionJob(claimed, correctionTimeoutSeconds(claimed.deadline_at, deps.now()));
695
+ await runIntegrationCorrectionSpawn(job, adapter.client, options, deps, driver, undefined, control);
696
+ return adapter.captured();
697
+ },
698
+ }));
699
+ function dispatchIntegrationAction(action) {
700
+ const key = --integrationSeq;
701
+ const promise = (async () => {
702
+ try {
703
+ await runOneIntegrationAction(action);
704
+ }
705
+ catch (err) {
706
+ const message = err instanceof Error ? err.message : String(err);
707
+ deps.errorLog(`executor integration action crashed: ${message.slice(0, 200)}`);
708
+ }
709
+ finally {
710
+ active.delete(key);
711
+ }
712
+ })();
713
+ active.set(key, promise);
714
+ }
715
+ /**
716
+ * Claim durable integration actions into whatever capacity is left
717
+ * (BAPI-1259). The claim scope is the executor's own: `--epic-run-id` runs,
718
+ * or the whole repository for `--repo-wide`. The action driver chooses no
719
+ * next ticket and no gate — it performs one action and reports facts.
720
+ */
721
+ async function claimIntegrationActions() {
722
+ const client = seams.integrationActions;
723
+ if (!client || integrationActionsUnsupported)
724
+ return;
725
+ for (const repoName of options.repos) {
726
+ while (!shutdownRequested && options.maxConcurrent - active.size > 0) {
727
+ const result = await client.claim({
728
+ protocol_version: INTEGRATION_ACTION_PROTOCOL_VERSION,
729
+ repo_name: repoName,
730
+ executor_id: options.executorId,
731
+ ...(options.epicRunIds !== undefined
732
+ ? { epic_run_ids: options.epicRunIds }
733
+ : { repo_wide: true }),
734
+ });
735
+ if (result.kind === "claimed") {
736
+ dispatchIntegrationAction(result.action);
737
+ continue;
738
+ }
739
+ if (result.kind === "fatal") {
740
+ if (result.authHttpStatus === 401 || result.authHttpStatus === 403) {
741
+ control.reportAuthFatal?.({
742
+ repoName,
743
+ category: "claim",
744
+ httpStatus: result.authHttpStatus,
745
+ });
746
+ }
747
+ else {
748
+ integrationActionsUnsupported = true;
749
+ deps.errorLog(`executor: integration actions unavailable on this server (${result.error}); ` +
750
+ "not claiming them again in this process");
751
+ }
752
+ return;
753
+ }
754
+ if (result.kind === "retryable") {
755
+ deps.errorLog(`executor integration action claim retryable: ${result.error}`);
756
+ }
757
+ break;
758
+ }
759
+ }
760
+ }
539
761
  // Handlers go on BEFORE preflight and before the first claim (BAPI-828).
540
762
  // Preflight can take seconds and the first claim can spawn a worker immediately
541
763
  // after it, so registering any later would leave a real window in which a
@@ -654,10 +876,25 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
654
876
  // cycle's claim loop (a claim-time auth-fatal is the case that matters —
655
877
  // it can flip `shutdownRequested` while `active.size` is still 0). No
656
878
  // worktree-mutating sweep may run once the auth-fatal flag is set.
879
+ // BAPI-1259 — integration actions take whatever capacity ordinary jobs
880
+ // left this cycle, under the same shutdown gate.
881
+ if (!shutdownRequested) {
882
+ await claimIntegrationActions();
883
+ }
657
884
  if (!shutdownRequested) {
658
885
  await maybeSweepWorktrees();
659
886
  }
660
887
  }
888
+ // BAPI-1279 — retire once the scoped run is finished and nothing is held.
889
+ // Checked whether or not preflight passed: an executor that preflight
890
+ // keeps refusing still holds a sleep assertion and a record, and must not
891
+ // outlive its run. `break` takes the SAME path a `--once` pass does:
892
+ // drain, stop the heartbeat loop, unsubscribe, exit 0.
893
+ if (!shutdownRequested && active.size === 0 && (await scopedRunIsTerminal())) {
894
+ runTerminal = true;
895
+ deps.errorLog(`executor: run ${observedEpicRunId} is finished and no job is held; exiting.`);
896
+ break;
897
+ }
661
898
  if (options.once || shutdownRequested)
662
899
  break;
663
900
  // Sample the clock immediately around the ACTUAL sleep, so the measured gap
@@ -707,6 +944,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
707
944
  }
708
945
  }
709
946
  }
947
+ if (runTerminal && observedEpicRunId !== null && seams.onRunTerminal) {
948
+ try {
949
+ await seams.onRunTerminal(observedEpicRunId);
950
+ }
951
+ catch {
952
+ /* record cleanup must never change a clean terminal exit */
953
+ }
954
+ }
710
955
  // A graceful signal shutdown is a NORMAL executor exit, not an error: the run did
711
956
  // exactly what it was asked to do. A distinct nonzero code here would make every
712
957
  // deliberate operator stop look like a crash to launchd, systemd, and the CLI
@@ -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
+ }