agent-merge-broker 0.5.0 → 0.6.0

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 (49) hide show
  1. package/README.md +54 -18
  2. package/dist/broker.d.ts +41 -1
  3. package/dist/broker.d.ts.map +1 -1
  4. package/dist/broker.js +241 -6
  5. package/dist/broker.js.map +1 -1
  6. package/dist/cli.js +120 -13
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +1 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +33 -2
  11. package/dist/config.js.map +1 -1
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/provenance.d.ts +12 -0
  17. package/dist/provenance.d.ts.map +1 -1
  18. package/dist/provenance.js +104 -0
  19. package/dist/provenance.js.map +1 -1
  20. package/dist/publisher.d.ts +3 -3
  21. package/dist/publisher.js +3 -3
  22. package/dist/serve-log.d.ts +67 -0
  23. package/dist/serve-log.d.ts.map +1 -0
  24. package/dist/serve-log.js +62 -0
  25. package/dist/serve-log.js.map +1 -0
  26. package/dist/service.d.ts +64 -0
  27. package/dist/service.d.ts.map +1 -0
  28. package/dist/service.js +200 -0
  29. package/dist/service.js.map +1 -0
  30. package/dist/store.d.ts +16 -0
  31. package/dist/store.d.ts.map +1 -1
  32. package/dist/store.js +73 -0
  33. package/dist/store.js.map +1 -1
  34. package/dist/types.d.ts +18 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/validation.d.ts.map +1 -1
  37. package/dist/validation.js +1 -1
  38. package/dist/validation.js.map +1 -1
  39. package/dist/verify.d.ts +8 -0
  40. package/dist/verify.d.ts.map +1 -1
  41. package/dist/verify.js +131 -42
  42. package/dist/verify.js.map +1 -1
  43. package/docs/ARCHITECTURE.md +26 -11
  44. package/docs/PROTOCOL.md +14 -4
  45. package/docs/RELEASING.md +17 -8
  46. package/docs/SECURITY.md +31 -6
  47. package/package.json +2 -2
  48. package/schemas/config.schema.json +18 -2
  49. package/schemas/provenance.schema.json +11 -1
package/README.md CHANGED
@@ -45,7 +45,7 @@ It is deliberately **not** an agent framework and **not** a replacement for prot
45
45
 
46
46
  `0.3.0` was the first public release: the local broker core, the GitHub CLI publishing adapter with auto-merge, and the remote provenance verifier.
47
47
 
48
- `0.5.0` is current. Since the first release the lifecycle has been made recoverable — work can be resubmitted and rescoped after a failure, pre-flight validation runs the same validators integration does, and a publication interrupted partway through can be retried rather than reconciled by hand.
48
+ `0.6.0` is current. Batch manifests are authenticated with a repository Ed25519 identity, post-assembly branch mutation is rejected, interrupted integration state recovers without rebuilding tasks, and the integration loop can run as a supervised per-user service.
49
49
 
50
50
  The on-disk state, receipt, and provenance formats are versioned, but compatibility is not guaranteed until `1.0.0`. Expect format migrations before then.
51
51
 
@@ -80,7 +80,10 @@ npm link
80
80
  - `.merge-broker/config.json` — repository policy and commands
81
81
  - `.merge-broker/agent-instructions.md` — a reusable worker contract
82
82
 
83
- Runtime state, receipt records, manifests, locks, and integration worktrees live under Git's common directory. Every linked worktree therefore sees the same broker state, while runtime artifacts do not pollute commits.
83
+ It also creates an Ed25519 provenance private key, mode `0600`, under Git's common runtime directory.
84
+ Only its public key is written to the committed configuration. Runtime state, receipt records,
85
+ manifests, keys, locks, and integration worktrees therefore stay outside commits while every linked
86
+ worktree sees the same broker authority.
84
87
 
85
88
  ## Quick start
86
89
 
@@ -88,6 +91,7 @@ Initialize an existing Git repository and edit its generated configuration:
88
91
 
89
92
  ```bash
90
93
  merge-broker init --base main --base-ref origin/main --remote origin
94
+ git add .merge-broker && git commit -m 'Configure authenticated merge brokerage'
91
95
  merge-broker doctor
92
96
  ```
93
97
 
@@ -178,29 +182,49 @@ This sets `core.hooksPath`, so it refuses to run when the repository already has
178
182
  stop working, and `--uninstall` puts everything back. `MERGE_BROKER_ALLOW_DIRECT_PUSH=1` is the
179
183
  deliberate emergency bypass.
180
184
 
181
- A remote gate proves a pull request really is an unaltered broker batch. It reads only Git, so it
182
- can run before any dependency is installed and reject bypassed work for almost nothing:
185
+ ### Publishing without a terminal
186
+
187
+ `serve` polls for verified batches and publishes them, but only while somebody keeps it running in a
188
+ terminal. When nobody does, a submitted task sits in `submitted` indefinitely — and to the agent
189
+ that submitted it, waiting forever is indistinguishable from being rejected. Install the loop as a
190
+ per-user service instead:
191
+
192
+ ```bash
193
+ merge-broker install-service
194
+ ```
195
+
196
+ This writes a launchd agent on macOS or a systemd user unit on Linux, one per repository, and starts
197
+ it. It is deliberately a *user* service on both platforms: a system daemon would need root and would
198
+ run as the wrong user for the repository's SSH and forge credentials. `--uninstall` removes it, and
199
+ the service writes to `$(git rev-parse --git-common-dir)/merge-broker/serve.log`, including when it
200
+ was installed from a linked worktree whose `.git` is a file.
201
+
202
+ A remote gate authenticates a pull request as an immutable broker batch. It reads the trusted public
203
+ key from the protected base branch, so it can reject unsigned, forged, or post-assembly work before
204
+ installing dependencies:
183
205
 
184
206
  ```yaml
185
207
  - uses: actions/checkout@v4
186
208
  with:
187
209
  ref: ${{ github.event.pull_request.head.sha }}
188
210
  fetch-depth: 0
189
- - uses: WeSpitfire/agent-merge-broker/verify@v1
211
+ - uses: WeSpitfire/agent-merge-broker/verify@v0.6.0
190
212
  ```
191
213
 
192
- The check confirms that the branch is a broker integration branch, that its manifest was assembled
193
- on real base history, that the final commit changes nothing but that manifest, that the integrated
194
- diff matches exactly the paths the receipts account for, that every submitted commit is present, and
195
- that all recorded validations passed. It accepts the "update branch" merges a protected base
196
- produces, and rejects a merge that brings in anything the base does not already contain.
214
+ The check verifies the Ed25519 signature, branch and batch identity, real base history, one-file
215
+ manifest commit, integrated diff, submitted commit trail, and recorded validation results. The
216
+ provenance commit must remain the branch head. Even a normal base-update merge can carry arbitrary
217
+ conflict resolution, so any post-assembly merge is rejected; re-cut a stale batch with `batch
218
+ refresh` instead.
197
219
 
198
220
  Verification policy is read from the configuration committed on the *base* branch, never from the
199
- change under review.
221
+ change under review. Repositories initialized before `0.6.0` must run `merge-broker provenance
222
+ setup-signing`, commit the resulting public-key policy, and keep the private key outside the working
223
+ tree. Until then verification reports structural-only rather than authenticated provenance.
200
224
 
201
225
  ## Automatic merging
202
226
 
203
- With `publish.mode` set to `pull-request` and `publish.autoMerge` enabled, the broker enables GitHub auto-merge on each published batch. GitHub lands the pull request once required status checks pass and updates the branch when the base branch requires it. The broker never pushes to the base branch itself, so branch protection remains the authority on what may merge.
227
+ With `publish.mode` set to `pull-request` and `publish.autoMerge` enabled, the broker enables GitHub auto-merge on each published batch. GitHub lands the pull request once required status checks pass. The broker never pushes to the base branch itself, so branch protection remains the authority on what may merge. If the base moves first, re-cut the batch; do not use GitHub's update-branch merge because immutable provenance deliberately rejects it.
204
228
 
205
229
  Two configuration combinations cannot work and are rejected at load time rather than stalling silently:
206
230
 
@@ -250,7 +274,13 @@ forge that fails halfway leaves a published batch carrying a `publishWarning` ra
250
274
  whose pull request exists but whose state does not admit it. Running `batch publish` again finds the
251
275
  existing pull request and retries what is left.
252
276
 
253
- A process that dies mid-integration leaves its lock behind. A holder on this machine is reclaimed automatically once its process is gone, but a holder on another machine cannot be probed at all and would otherwise block integration for the full stale window. `merge-broker unlock` reports lock state and releases a lock whose owner is provably gone; `--force` overrides that check and should follow confirming that no integration is running.
277
+ A process that dies mid-integration can leave both a lock and durable `running` state. A holder on
278
+ this machine is reclaimed automatically once its process is gone; a holder on another machine cannot
279
+ be probed and waits out the stale window. Once the integration lock is safely acquired, `serve` and
280
+ `integrate` automatically mark the abandoned batch failed, clean its broker-owned worktree and
281
+ branch, and return its tasks to `submitted` without spending their attempt budget. `merge-broker
282
+ recover` performs that reconciliation explicitly. `unlock --force` remains only for an owner that
283
+ cannot be proven gone and must follow confirming no integration is active.
254
284
 
255
285
  ## Housekeeping
256
286
 
@@ -298,7 +328,9 @@ The generated `.merge-broker/config.json` is intentionally explicit and reviewab
298
328
  "maxAttempts": 3,
299
329
  "provenance": {
300
330
  "enabled": true,
301
- "directory": ".merge-broker/attestations"
331
+ "directory": ".merge-broker/attestations",
332
+ "requireSignature": true,
333
+ "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
302
334
  }
303
335
  },
304
336
  "validation": {
@@ -330,8 +362,9 @@ The generated `.merge-broker/config.json` is intentionally explicit and reviewab
330
362
  Validator commands run inside the isolated integration worktree, under `/bin/sh` unless
331
363
  `validation.shell` names another interpreter. The shell is deliberately fixed and is never a login
332
364
  shell: an integration decision must not depend on whose machine assembled the batch. The environment
333
- is inherited from the process that invoked the broker, minus `MERGE_BROKER_TOKEN`, so PATH and
334
- toolchain managers work while worker credentials stay out of repository-defined commands.
365
+ is inherited from the process that invoked the broker, minus `MERGE_BROKER_TOKEN`,
366
+ `MERGE_BROKER_SIGNING_KEY`, and `MERGE_BROKER_SIGNING_KEY_FILE`, so PATH and toolchain managers work
367
+ while broker credentials stay out of repository-defined commands.
335
368
 
336
369
  Validators receive these environment variables:
337
370
 
@@ -353,8 +386,8 @@ The JSON schemas in [`schemas/`](schemas/) can be used by editors, adapters, and
353
386
  ### One authoritative CI pass
354
387
 
355
388
  Repositories that make GitHub the authoritative validator can leave broker
356
- authoritative validators empty, keep provenance enabled, and reject any PR
357
- without a valid broker manifest before installing dependencies. The full lint,
389
+ authoritative validators empty, require signed provenance, and reject any PR
390
+ without an authenticated broker manifest before installing dependencies. The full lint,
358
391
  type, test, and build suite then runs exactly once on the assembled broker PR.
359
392
  Task worktrees retain only fast changed-scope feedback, while deployment builds
360
393
  the already-checked revision without repeating the whole suite.
@@ -364,7 +397,9 @@ the already-checked revision without repeating the whole suite.
364
397
  ```text
365
398
  merge-broker init
366
399
  merge-broker doctor
400
+ merge-broker provenance setup-signing [--private-key <path>] [--rotate]
367
401
  merge-broker install-hooks [--force] [--uninstall]
402
+ merge-broker install-service [--uninstall] [--interval <seconds>] [--no-eager]
368
403
  merge-broker verify-provenance --branch <ref> --head <sha> --base <sha>
369
404
  merge-broker validate [--task <id>] [--scope focused|authoritative|all] [--base <ref>] [--cwd <path>]
370
405
  merge-broker task register|claim|extend|heartbeat|submit|retry|release|cancel|show
@@ -377,6 +412,7 @@ merge-broker metrics
377
412
  merge-broker events
378
413
  merge-broker prune [--older-than <days>] [--dry-run]
379
414
  merge-broker unlock [state|integration] [--force]
415
+ merge-broker recover
380
416
  merge-broker serve [--publish] [--eager]
381
417
  ```
382
418
 
package/dist/broker.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { GitRepository } from "./git.js";
2
2
  import { StateStore, type LockStatus } from "./store.js";
3
3
  import { type HookInstallation } from "./hooks.js";
4
- import type { BatchRecord, BrokerConfig, BrokerState, IntegrationOptions, IntegrationResult, LocalValidationResult, RefreshResult, PruneOptions, PruneResult, SchedulePlan, TaskRecord } from "./types.js";
4
+ import { type ServiceInstallation } from "./service.js";
5
+ import type { BatchRecord, BrokerConfig, BrokerState, IntegrationOptions, IntegrationResult, LocalValidationResult, RefreshResult, RecoveryResult, PruneOptions, PruneResult, SchedulePlan, TaskRecord } from "./types.js";
5
6
  export interface RegisterTaskInput {
6
7
  id: string;
7
8
  title?: string;
@@ -35,6 +36,19 @@ export declare class MergeBroker {
35
36
  configPath: string;
36
37
  created: boolean;
37
38
  }>;
39
+ /**
40
+ * Enables authenticated provenance for an existing repository or rotates/imports its identity.
41
+ * The private key stays in Git's runtime state; configuration receives only the public key.
42
+ */
43
+ setupProvenanceSigning(options?: {
44
+ privateKeyFile?: string;
45
+ rotate?: boolean;
46
+ }): Promise<{
47
+ publicKey: string;
48
+ keyId: string;
49
+ keyPath: string;
50
+ }>;
51
+ private provenanceSigningPrivateKey;
38
52
  registerTask(input: RegisterTaskInput): Promise<TaskRecord>;
39
53
  claimTask(input: ClaimTaskInput): Promise<{
40
54
  task: TaskRecord;
@@ -81,6 +95,12 @@ export declare class MergeBroker {
81
95
  cwd?: string;
82
96
  }): Promise<LocalValidationResult>;
83
97
  integrate(options?: IntegrationOptions): Promise<IntegrationResult>;
98
+ private recoverAbandonedIntegrationsLocked;
99
+ /**
100
+ * Recovers durable `running` state left by a killed integration process. Acquiring the integration
101
+ * lock is the proof that the former process is no longer allowed to make progress.
102
+ */
103
+ recoverAbandonedIntegrations(): Promise<RecoveryResult>;
84
104
  publishBatch(id: string): Promise<BatchRecord>;
85
105
  syncBatch(id: string): Promise<BatchRecord>;
86
106
  syncPublishedBatches(): Promise<{
@@ -136,6 +156,26 @@ export declare class MergeBroker {
136
156
  force?: boolean;
137
157
  uninstall?: boolean;
138
158
  }): Promise<HookInstallation>;
159
+ /**
160
+ * Installs the integration loop as a per-user service.
161
+ *
162
+ * Without it `serve` only runs while a terminal is open, so a submitted task
163
+ * waits for a human to notice — indistinguishable, to the agent that
164
+ * submitted it, from the broker rejecting the work.
165
+ */
166
+ installService(options?: {
167
+ uninstall?: boolean;
168
+ intervalSeconds?: number;
169
+ eager?: boolean;
170
+ nodePath?: string;
171
+ cliPath?: string;
172
+ pathEntries?: string[];
173
+ logFile?: string;
174
+ }): Promise<ServiceInstallation | {
175
+ name: string;
176
+ file: string;
177
+ removed: boolean;
178
+ }>;
139
179
  inspectLocks(): Promise<LockStatus[]>;
140
180
  releaseLock(name: string, options?: {
141
181
  force?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"broker.d.ts","sourceRoot":"","sources":["../src/broker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"broker.d.ts","sourceRoot":"","sources":["../src/broker.ts"],"names":[],"mappings":"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"}
package/dist/broker.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import path from "node:path";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { BrokerError, CommandError, ValidationError } from "./errors.js";
4
5
  import { GitRepository } from "./git.js";
5
- import { initializeConfig, loadConfig } from "./config.js";
6
+ import { initializeConfig, loadConfig, writeConfig } from "./config.js";
6
7
  import { patternSetsMayOverlap, unexpectedPaths } from "./patterns.js";
7
8
  import { scheduleTasks } from "./scheduler.js";
8
9
  import { StateStore } from "./store.js";
9
10
  import { runValidators } from "./validation.js";
10
11
  import { closePullRequest, enableAutoMerge, inspectPullRequest, publishBatch as publishPreparedBatch, } from "./publisher.js";
11
- import { buildBatchProvenance, provenancePath } from "./provenance.js";
12
+ import { buildBatchProvenance, provenanceKeyId, provenancePath, publicKeyFromPrivate, signBatchProvenance, } from "./provenance.js";
12
13
  import { installHooks, uninstallHooks } from "./hooks.js";
14
+ import { installService, uninstallService, } from "./service.js";
13
15
  function now() {
14
16
  return new Date().toISOString();
15
17
  }
@@ -189,8 +191,62 @@ export class MergeBroker {
189
191
  const initialized = await initializeConfig(repo.root, options);
190
192
  const store = new StateStore(repo.commonGitDir, initialized.config.stateDirectory, initialized.config.leases.lockTimeoutSeconds);
191
193
  await store.initialize();
194
+ const provenance = initialized.config.integration.provenance;
195
+ if (initialized.created && provenance?.enabled) {
196
+ const identity = await store.provisionProvenanceSigningKey();
197
+ provenance.requireSignature = true;
198
+ provenance.publicKey = identity.publicKey;
199
+ await writeConfig(repo.root, initialized.config);
200
+ }
192
201
  return { repoRoot: repo.root, configPath: initialized.path, created: initialized.created };
193
202
  }
203
+ /**
204
+ * Enables authenticated provenance for an existing repository or rotates/imports its identity.
205
+ * The private key stays in Git's runtime state; configuration receives only the public key.
206
+ */
207
+ async setupProvenanceSigning(options = {}) {
208
+ const privateKey = options.privateKeyFile
209
+ ? await readFile(path.resolve(options.privateKeyFile), "utf8")
210
+ : undefined;
211
+ const identity = await this.store.provisionProvenanceSigningKey({
212
+ ...(privateKey ? { privateKey } : {}),
213
+ rotate: options.rotate ?? false,
214
+ });
215
+ this.config.integration.provenance ??= {
216
+ enabled: true,
217
+ directory: ".merge-broker/attestations",
218
+ };
219
+ this.config.integration.provenance.enabled = true;
220
+ this.config.integration.provenance.requireSignature = true;
221
+ this.config.integration.provenance.publicKey = identity.publicKey;
222
+ await writeConfig(this.repo.root, this.config);
223
+ return identity;
224
+ }
225
+ async provenanceSigningPrivateKey() {
226
+ const provenance = this.config.integration.provenance;
227
+ if (!provenance?.enabled || !provenance.publicKey) {
228
+ if (provenance?.requireSignature) {
229
+ throw new BrokerError("SIGNING_KEY_REQUIRED", "Signed provenance is required, but configuration has no trusted public key. Run `merge-broker provenance setup-signing`.");
230
+ }
231
+ return undefined;
232
+ }
233
+ const fromEnvironment = process.env.MERGE_BROKER_SIGNING_KEY;
234
+ const fromFile = process.env.MERGE_BROKER_SIGNING_KEY_FILE
235
+ ? await readFile(path.resolve(process.env.MERGE_BROKER_SIGNING_KEY_FILE), "utf8")
236
+ : undefined;
237
+ const privateKey = fromEnvironment ?? fromFile ?? (await this.store.readProvenanceSigningKey(provenance.publicKey));
238
+ if (!privateKey) {
239
+ if (!provenance.requireSignature)
240
+ return undefined;
241
+ throw new BrokerError("SIGNING_KEY_REQUIRED", `Signed provenance is required, but the private key is unavailable. Restore ${this.store.provenanceSigningKeyFile}, set MERGE_BROKER_SIGNING_KEY_FILE, or import it with \`merge-broker provenance setup-signing --private-key <path>\`.`);
242
+ }
243
+ const expectedKeyId = provenanceKeyId(provenance.publicKey);
244
+ const actualKeyId = provenanceKeyId(publicKeyFromPrivate(privateKey));
245
+ if (expectedKeyId !== actualKeyId) {
246
+ throw new BrokerError("SIGNING_KEY_MISMATCH", "The available provenance private key does not match the public key committed in configuration.", { expectedKeyId, actualKeyId });
247
+ }
248
+ return privateKey;
249
+ }
194
250
  async registerTask(input) {
195
251
  assertTaskId(input.id);
196
252
  if (input.priority !== undefined && !Number.isInteger(input.priority)) {
@@ -589,6 +645,13 @@ export class MergeBroker {
589
645
  }
590
646
  async integrate(options = {}) {
591
647
  return await this.store.withIntegrationLock(async () => {
648
+ // A killed process can leave durable state at `running`/`integrating` after its lock is gone.
649
+ // Owning the integration lock proves nobody else can still be advancing that transaction, so
650
+ // recover it before planning. This also lets the recovered tasks participate immediately.
651
+ await this.recoverAbandonedIntegrationsLocked();
652
+ // Key problems are operator/configuration failures, not task failures. Resolve them before
653
+ // tasks move to `integrating`, so a missing key cannot spend a worker's retry budget.
654
+ const signingPrivateKey = await this.provenanceSigningPrivateKey();
592
655
  // One batch in flight at a time.
593
656
  //
594
657
  // A batch is cut from the base branch tip so it is born mergeable. Cutting a second one while
@@ -705,13 +768,19 @@ export class MergeBroker {
705
768
  const integratedHeadSha = headSha;
706
769
  const integratedPaths = await this.repo.changedFilesBetween(baseSha, integratedHeadSha);
707
770
  const relativePath = provenancePath(provenance.directory, id);
708
- const record = buildBatchProvenance({
771
+ let record = buildBatchProvenance({
709
772
  batch,
710
773
  tasks: plan.selected,
711
774
  integratedHeadSha,
712
775
  integratedPaths,
713
776
  history: this.config.integration.history,
714
777
  });
778
+ if (signingPrivateKey && provenance.publicKey) {
779
+ record = signBatchProvenance(record, signingPrivateKey, provenance.publicKey);
780
+ }
781
+ else if (provenance.requireSignature) {
782
+ throw new BrokerError("SIGNING_KEY_REQUIRED", "Signed provenance is required for this batch.");
783
+ }
715
784
  headSha = await this.repo.commitGeneratedFile(worktree, relativePath, `${JSON.stringify(record, null, 2)}\n`, `Record Merge Broker batch ${id}`);
716
785
  batch.integratedHeadSha = integratedHeadSha;
717
786
  batch.provenancePath = relativePath;
@@ -837,6 +906,104 @@ export class MergeBroker {
837
906
  };
838
907
  });
839
908
  }
909
+ async recoverAbandonedIntegrationsLocked() {
910
+ const snapshot = await this.store.read();
911
+ const running = Object.values(snapshot.batches).filter((batch) => batch.status === "running");
912
+ if (running.length === 0) {
913
+ return {
914
+ batches: [],
915
+ tasks: [],
916
+ worktreesRemoved: [],
917
+ branchesRemoved: [],
918
+ cleanupWarnings: [],
919
+ };
920
+ }
921
+ const recovered = await this.store.transaction((state, audit) => {
922
+ const batches = [];
923
+ const tasks = [];
924
+ for (const candidate of running) {
925
+ const batch = state.batches[candidate.id];
926
+ if (!batch || batch.status !== "running")
927
+ continue;
928
+ batch.status = "failed";
929
+ batch.error = "Integration process stopped before the transaction completed; tasks were recovered for retry.";
930
+ batch.finishedAt = now();
931
+ batches.push(batch.id);
932
+ const requeued = [];
933
+ for (const taskId of batch.taskIds) {
934
+ const task = state.tasks[taskId];
935
+ if (!task || task.status !== "integrating" || task.batchId !== batch.id)
936
+ continue;
937
+ task.status = "submitted";
938
+ task.updatedAt = now();
939
+ delete task.batchId;
940
+ delete task.lastError;
941
+ tasks.push(taskId);
942
+ requeued.push(taskId);
943
+ }
944
+ audit("batch.recovered", {
945
+ batchId: batch.id,
946
+ details: { requeued, reason: "abandoned integration transaction" },
947
+ });
948
+ }
949
+ return { batches, tasks };
950
+ });
951
+ const worktreesRemoved = [];
952
+ const branchesRemoved = [];
953
+ const cleanupWarnings = [];
954
+ const registeredWorktrees = new Set((await this.repo.listWorktrees()).map((worktree) => path.resolve(worktree.path)));
955
+ const worktreeRoot = `${path.resolve(this.store.worktreesDirectory)}${path.sep}`;
956
+ for (const batchId of recovered.batches) {
957
+ const candidate = running.find((batch) => batch.id === batchId);
958
+ const worktree = candidate?.worktree ? path.resolve(candidate.worktree) : undefined;
959
+ if (worktree && registeredWorktrees.has(worktree)) {
960
+ if (!worktree.startsWith(worktreeRoot)) {
961
+ cleanupWarnings.push(`Refused to remove recovery worktree outside broker state: ${worktree}`);
962
+ }
963
+ else {
964
+ try {
965
+ await this.repo.removeWorktree(worktree);
966
+ worktreesRemoved.push(worktree);
967
+ }
968
+ catch (error) {
969
+ cleanupWarnings.push(errorMessage(error));
970
+ }
971
+ }
972
+ }
973
+ const branchName = cleanBranchFragment(`${this.config.integration.branchPrefix}${batchId}`);
974
+ const branch = await this.repo.git(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], this.repo.root, true);
975
+ if (branch.exitCode === 0) {
976
+ const deleted = await this.repo.git(["branch", "-D", "--", branchName], this.repo.root, true);
977
+ if (deleted.exitCode === 0)
978
+ branchesRemoved.push(branchName);
979
+ else
980
+ cleanupWarnings.push(`Could not remove recovery branch ${branchName}: ${deleted.stderr.trim()}`);
981
+ }
982
+ }
983
+ await this.store.transaction((state, audit) => {
984
+ for (const batchId of recovered.batches) {
985
+ const batch = state.batches[batchId];
986
+ if (!batch)
987
+ continue;
988
+ if (worktreesRemoved.includes(path.resolve(batch.worktree ?? "")))
989
+ delete batch.worktree;
990
+ if (cleanupWarnings.length > 0) {
991
+ batch.error = `${batch.error ?? "Integration was recovered."} Cleanup: ${cleanupWarnings.join("; ")}`;
992
+ }
993
+ }
994
+ audit("integration.recovery_completed", {
995
+ details: { ...recovered, worktreesRemoved, branchesRemoved, cleanupWarnings },
996
+ });
997
+ });
998
+ return { ...recovered, worktreesRemoved, branchesRemoved, cleanupWarnings };
999
+ }
1000
+ /**
1001
+ * Recovers durable `running` state left by a killed integration process. Acquiring the integration
1002
+ * lock is the proof that the former process is no longer allowed to make progress.
1003
+ */
1004
+ async recoverAbandonedIntegrations() {
1005
+ return await this.store.withIntegrationLock(async () => await this.recoverAbandonedIntegrationsLocked());
1006
+ }
840
1007
  async publishBatch(id) {
841
1008
  const state = await this.store.read();
842
1009
  const batch = requireBatch(state, id);
@@ -1045,6 +1212,9 @@ export class MergeBroker {
1045
1212
  let pullRequestClosed;
1046
1213
  if (batch.pullRequestUrl) {
1047
1214
  pullRequestClosed = await closePullRequest(this.repo.root, batch.pullRequestUrl, `Superseded: the base branch moved to ${currentBase.slice(0, 7)}, so this batch was re-cut from the current tip.`);
1215
+ if (!pullRequestClosed) {
1216
+ throw new BrokerError("PULL_REQUEST_CLOSE_FAILED", `Could not close superseded pull request ${batch.pullRequestUrl}. The existing batch and its tasks were left unchanged so two mergeable copies cannot exist. Retry when the forge responds.`, { batchId: id, pullRequestUrl: batch.pullRequestUrl });
1217
+ }
1048
1218
  }
1049
1219
  if (batch.branchName)
1050
1220
  await this.repo.deleteBranch(batch.branchName);
@@ -1188,6 +1358,43 @@ export class MergeBroker {
1188
1358
  force: options.force ?? false,
1189
1359
  });
1190
1360
  }
1361
+ /**
1362
+ * Installs the integration loop as a per-user service.
1363
+ *
1364
+ * Without it `serve` only runs while a terminal is open, so a submitted task
1365
+ * waits for a human to notice — indistinguishable, to the agent that
1366
+ * submitted it, from the broker rejecting the work.
1367
+ */
1368
+ async installService(options = {}) {
1369
+ if (options.uninstall)
1370
+ return await uninstallService(this.repo.root);
1371
+ const nodePath = options.nodePath ?? process.execPath;
1372
+ const cliPath = options.cliPath ?? process.argv[1] ?? "";
1373
+ if (!path.isAbsolute(cliPath)) {
1374
+ throw new BrokerError("SERVICE_CLI_PATH", "Could not determine an absolute path to the broker CLI. Pass --cli-path.");
1375
+ }
1376
+ return await installService({
1377
+ repositoryRoot: this.repo.root,
1378
+ nodePath,
1379
+ cliPath,
1380
+ intervalSeconds: options.intervalSeconds ?? 15,
1381
+ eager: options.eager ?? true,
1382
+ // node's own directory is included because a version-managed node is not
1383
+ // on the default PATH a login-less agent receives.
1384
+ pathEntries: options.pathEntries ?? [
1385
+ path.dirname(nodePath),
1386
+ "/opt/homebrew/bin",
1387
+ "/usr/local/bin",
1388
+ "/usr/bin",
1389
+ "/bin",
1390
+ "/usr/sbin",
1391
+ "/sbin",
1392
+ ],
1393
+ // `.git` is a file in linked worktrees. Runtime state already resolves Git's common directory,
1394
+ // which is the one log location every checkout can safely create and inspect.
1395
+ logFile: options.logFile ?? path.join(this.store.directory, "serve.log"),
1396
+ });
1397
+ }
1191
1398
  async inspectLocks() {
1192
1399
  return await Promise.all(["state", "integration"].map(async (name) => await this.store.inspectLock(name)));
1193
1400
  }
@@ -1240,15 +1447,40 @@ export class MergeBroker {
1240
1447
  };
1241
1448
  }
1242
1449
  async doctor() {
1243
- const [baseSha, clean, worktrees, locks] = await Promise.all([
1450
+ const [baseSha, clean, worktrees, locks, state] = await Promise.all([
1244
1451
  this.repo.resolveCommit(this.config.baseRef),
1245
1452
  this.repo.isClean(),
1246
1453
  this.repo.listWorktrees(),
1247
1454
  this.inspectLocks(),
1455
+ this.store.read(),
1248
1456
  ]);
1249
1457
  const warnings = [];
1458
+ let ok = true;
1459
+ const provenance = this.config.integration.provenance;
1460
+ let signingKeyId;
1461
+ if (provenance?.enabled && provenance.requireSignature) {
1462
+ try {
1463
+ await this.provenanceSigningPrivateKey();
1464
+ if (provenance.publicKey)
1465
+ signingKeyId = provenanceKeyId(provenance.publicKey);
1466
+ }
1467
+ catch (error) {
1468
+ ok = false;
1469
+ warnings.push(errorMessage(error));
1470
+ }
1471
+ }
1472
+ else if (provenance?.enabled) {
1473
+ warnings.push("Provenance signatures are not required, so remote verification can confirm structure but cannot authenticate which broker created it. Run `merge-broker provenance setup-signing`.");
1474
+ }
1250
1475
  if (this.config.validation.focused.length === 0 && this.config.validation.authoritative.length === 0) {
1251
- warnings.push("No validators are configured, so batches are assembled without being checked. Configure validation.authoritative, or rely on a remote gate that verifies broker provenance.");
1476
+ warnings.push("No validators are configured, so batches are assembled without being checked. Configure validation.authoritative or require an authoritative remote CI suite on every batch PR.");
1477
+ }
1478
+ const runningBatches = Object.values(state.batches)
1479
+ .filter((batch) => batch.status === "running")
1480
+ .map((batch) => batch.id);
1481
+ if (runningBatches.length > 0) {
1482
+ ok = false;
1483
+ warnings.push(`Integration state is incomplete for ${runningBatches.join(", ")}. Run \`merge-broker recover\` after confirming no broker process is active.`);
1252
1484
  }
1253
1485
  for (const lock of locks) {
1254
1486
  if (!lock.held)
@@ -1256,7 +1488,7 @@ export class MergeBroker {
1256
1488
  warnings.push(`The ${lock.name} lock is held${lock.owner?.host ? ` by ${lock.owner.host}` : ""} and is ${Math.round((lock.ageMs ?? 0) / 1_000)}s old${lock.abandoned ? "; its owning process is gone, so \"merge-broker unlock\" can clear it" : ""}.`);
1257
1489
  }
1258
1490
  return {
1259
- ok: true,
1491
+ ok,
1260
1492
  warnings,
1261
1493
  locks,
1262
1494
  repository: this.repo.root,
@@ -1271,6 +1503,9 @@ export class MergeBroker {
1271
1503
  publishMode: this.config.publish.mode,
1272
1504
  focusedValidators: this.config.validation.focused.map((validator) => validator.name),
1273
1505
  authoritativeValidators: this.config.validation.authoritative.map((validator) => validator.name),
1506
+ provenanceAuthenticated: Boolean(provenance?.enabled && provenance.requireSignature && signingKeyId),
1507
+ provenanceKeyId: signingKeyId,
1508
+ runningBatches,
1274
1509
  };
1275
1510
  }
1276
1511
  }