agent-merge-broker 0.3.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.
- package/LICENSE +201 -0
- package/README.md +352 -0
- package/dist/broker.d.ts +110 -0
- package/dist/broker.d.ts.map +1 -0
- package/dist/broker.js +1023 -0
- package/dist/broker.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +558 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +312 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/errors.js.map +1 -0
- package/dist/git.d.ts +46 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +195 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +110 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns.d.ts +9 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +66 -0
- package/dist/patterns.js.map +1 -0
- package/dist/process.d.ts +30 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +91 -0
- package/dist/process.js.map +1 -0
- package/dist/provenance.d.ts +10 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +36 -0
- package/dist/provenance.js.map +1 -0
- package/dist/publisher.d.ts +37 -0
- package/dist/publisher.d.ts.map +1 -0
- package/dist/publisher.js +144 -0
- package/dist/publisher.js.map +1 -0
- package/dist/scheduler.d.ts +6 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +96 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/store.d.ts +73 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +323 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +13 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +75 -0
- package/dist/validation.js.map +1 -0
- package/dist/verify.d.ts +40 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +177 -0
- package/dist/verify.js.map +1 -0
- package/docs/ARCHITECTURE.md +144 -0
- package/docs/PROTOCOL.md +127 -0
- package/docs/RELEASING.md +23 -0
- package/docs/SECURITY.md +57 -0
- package/package.json +68 -0
- package/schemas/config.schema.json +149 -0
- package/schemas/provenance.schema.json +56 -0
- package/schemas/receipt.schema.json +39 -0
- package/templates/AGENTS.snippet.md +5 -0
package/dist/broker.js
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { BrokerError, CommandError, ValidationError } from "./errors.js";
|
|
4
|
+
import { GitRepository } from "./git.js";
|
|
5
|
+
import { initializeConfig, loadConfig } from "./config.js";
|
|
6
|
+
import { patternSetsMayOverlap, unexpectedPaths } from "./patterns.js";
|
|
7
|
+
import { scheduleTasks } from "./scheduler.js";
|
|
8
|
+
import { StateStore } from "./store.js";
|
|
9
|
+
import { runValidators } from "./validation.js";
|
|
10
|
+
import { inspectPullRequest, publishBatch as publishPreparedBatch } from "./publisher.js";
|
|
11
|
+
import { buildBatchProvenance, provenancePath } from "./provenance.js";
|
|
12
|
+
import { installHooks, uninstallHooks } from "./hooks.js";
|
|
13
|
+
function now() {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
function hashToken(token) {
|
|
17
|
+
return createHash("sha256").update(token).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
function createToken() {
|
|
20
|
+
return randomBytes(24).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
function leaseExpired(task, at = Date.now()) {
|
|
23
|
+
return Boolean(task.lease && Date.parse(task.lease.expiresAt) <= at);
|
|
24
|
+
}
|
|
25
|
+
function requireTask(state, taskId) {
|
|
26
|
+
const task = Object.hasOwn(state.tasks, taskId) ? state.tasks[taskId] : undefined;
|
|
27
|
+
if (!task)
|
|
28
|
+
throw new BrokerError("UNKNOWN_TASK", `Unknown task: ${taskId}`);
|
|
29
|
+
return task;
|
|
30
|
+
}
|
|
31
|
+
function requireBatch(state, id) {
|
|
32
|
+
const batch = Object.hasOwn(state.batches, id) ? state.batches[id] : undefined;
|
|
33
|
+
if (!batch)
|
|
34
|
+
throw new BrokerError("UNKNOWN_BATCH", `Unknown batch: ${id}`);
|
|
35
|
+
return batch;
|
|
36
|
+
}
|
|
37
|
+
function assertTaskId(taskId) {
|
|
38
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:@/-]{0,127}$/u.test(taskId) ||
|
|
39
|
+
new Set(["constructor", "prototype", "__proto__"]).has(taskId)) {
|
|
40
|
+
throw new BrokerError("INVALID_TASK", "Task ID must be 1-128 safe identifier characters and cannot be a JavaScript prototype key.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function verifyLease(task, token) {
|
|
44
|
+
if (!task.lease)
|
|
45
|
+
throw new BrokerError("LEASE_REQUIRED", `Task ${task.id} does not have an active lease.`);
|
|
46
|
+
if (leaseExpired(task))
|
|
47
|
+
throw new BrokerError("LEASE_EXPIRED", `The lease for task ${task.id} has expired.`);
|
|
48
|
+
if (task.lease.tokenHash !== hashToken(token)) {
|
|
49
|
+
throw new BrokerError("LEASE_TOKEN", `Invalid lease token for task ${task.id}.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Editing leases are coordinated on declared globs, before any commit exists. Two glob languages
|
|
54
|
+
* cannot always be proven disjoint cheaply, so overlap is treated conservatively as a conflict.
|
|
55
|
+
*/
|
|
56
|
+
function assertNoLeaseConflict(state, taskId, expectedPaths, serializedPatterns) {
|
|
57
|
+
for (const existing of Object.values(state.tasks)) {
|
|
58
|
+
if (existing.id === taskId || !existing.lease || leaseExpired(existing))
|
|
59
|
+
continue;
|
|
60
|
+
if (patternSetsMayOverlap(expectedPaths, existing.expectedPaths)) {
|
|
61
|
+
throw new BrokerError("LEASE_CONFLICT", `Expected paths overlap active task ${existing.id}, leased by ${existing.lease.holder}.`, { conflictingTask: existing.id });
|
|
62
|
+
}
|
|
63
|
+
const resources = serializedPatterns.filter((resource) => patternSetsMayOverlap(expectedPaths, [resource]));
|
|
64
|
+
const existingResources = serializedPatterns.filter((resource) => patternSetsMayOverlap(existing.expectedPaths, [resource]));
|
|
65
|
+
const sharedResource = resources.find((resource) => existingResources.includes(resource));
|
|
66
|
+
if (sharedResource) {
|
|
67
|
+
throw new BrokerError("LEASE_CONFLICT", `Serialized resource ${sharedResource} is leased by task ${existing.id}.`, {
|
|
68
|
+
conflictingTask: existing.id,
|
|
69
|
+
resource: sharedResource,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function batchId() {
|
|
75
|
+
const stamp = new Date().toISOString().replace(/[-:.]/gu, "").replace("Z", "Z");
|
|
76
|
+
return `${stamp}-${randomBytes(3).toString("hex")}`;
|
|
77
|
+
}
|
|
78
|
+
function cleanBranchFragment(value) {
|
|
79
|
+
const cleaned = value.replace(/[^a-zA-Z0-9._/-]/gu, "-").replace(/\.{2,}/gu, ".");
|
|
80
|
+
return cleaned.replace(/^[-./]+/u, "") || "merge-broker/batch";
|
|
81
|
+
}
|
|
82
|
+
function errorMessage(error) {
|
|
83
|
+
if (error instanceof ValidationError)
|
|
84
|
+
return error.message;
|
|
85
|
+
if (error instanceof CommandError)
|
|
86
|
+
return `${error.message}\n${error.stderr.trim()}`.trim();
|
|
87
|
+
return error instanceof Error ? error.message : String(error);
|
|
88
|
+
}
|
|
89
|
+
function validationResultsFromError(error) {
|
|
90
|
+
if (!(error instanceof ValidationError))
|
|
91
|
+
return [];
|
|
92
|
+
const results = error.details?.completedValidations;
|
|
93
|
+
return Array.isArray(results) ? results : [];
|
|
94
|
+
}
|
|
95
|
+
const PRUNABLE_TASK_STATUSES = new Set(["merged", "cancelled"]);
|
|
96
|
+
const PRUNABLE_BATCH_STATUSES = new Set(["merged", "closed", "failed"]);
|
|
97
|
+
function taskRetiredAt(task) {
|
|
98
|
+
return Date.parse(task.mergedAt ?? task.updatedAt);
|
|
99
|
+
}
|
|
100
|
+
function batchRetiredAt(batch) {
|
|
101
|
+
return Date.parse(batch.finishedAt ?? batch.closedAt ?? batch.createdAt);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Chooses completed records that are safe to retire. An unparsable timestamp compares false and so
|
|
105
|
+
* keeps the record: forgetting work is worse than keeping it a while longer.
|
|
106
|
+
*/
|
|
107
|
+
function selectPrunable(state, cutoffMs) {
|
|
108
|
+
const candidateIds = new Set(Object.values(state.tasks)
|
|
109
|
+
.filter((task) => PRUNABLE_TASK_STATUSES.has(task.status) && taskRetiredAt(task) <= cutoffMs)
|
|
110
|
+
.map((task) => task.id));
|
|
111
|
+
// Removing a task that a retained task still depends on would strand the dependent forever: the
|
|
112
|
+
// scheduler cannot distinguish a pruned dependency from one that has never merged.
|
|
113
|
+
const retainedForDependencies = new Set();
|
|
114
|
+
for (const task of Object.values(state.tasks)) {
|
|
115
|
+
if (candidateIds.has(task.id))
|
|
116
|
+
continue;
|
|
117
|
+
for (const dependency of task.dependsOn) {
|
|
118
|
+
if (candidateIds.has(dependency))
|
|
119
|
+
retainedForDependencies.add(dependency);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const id of retainedForDependencies)
|
|
123
|
+
candidateIds.delete(id);
|
|
124
|
+
const batchIds = new Set(Object.values(state.batches)
|
|
125
|
+
.filter((batch) => PRUNABLE_BATCH_STATUSES.has(batch.status) && batchRetiredAt(batch) <= cutoffMs)
|
|
126
|
+
// A batch whose tasks are staying would become an orphaned reference; retire the two together.
|
|
127
|
+
.filter((batch) => batch.taskIds.every((id) => candidateIds.has(id) || !Object.hasOwn(state.tasks, id)))
|
|
128
|
+
.map((batch) => batch.id));
|
|
129
|
+
const tasks = [...candidateIds].filter((id) => {
|
|
130
|
+
const batchId = state.tasks[id]?.batchId;
|
|
131
|
+
return !batchId || batchIds.has(batchId) || !Object.hasOwn(state.batches, batchId);
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
tasks: tasks.sort(),
|
|
135
|
+
batches: [...batchIds].sort(),
|
|
136
|
+
retainedForDependencies: [...retainedForDependencies].sort(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export class MergeBroker {
|
|
140
|
+
repo;
|
|
141
|
+
config;
|
|
142
|
+
store;
|
|
143
|
+
constructor(repo, config, store) {
|
|
144
|
+
this.repo = repo;
|
|
145
|
+
this.config = config;
|
|
146
|
+
this.store = store;
|
|
147
|
+
}
|
|
148
|
+
static async open(cwd = process.cwd()) {
|
|
149
|
+
const repo = await GitRepository.discover(cwd);
|
|
150
|
+
const config = await loadConfig(repo.root);
|
|
151
|
+
const store = new StateStore(repo.commonGitDir, config.stateDirectory, config.leases.lockTimeoutSeconds);
|
|
152
|
+
await store.initialize();
|
|
153
|
+
return new MergeBroker(repo, config, store);
|
|
154
|
+
}
|
|
155
|
+
static async initialize(cwd = process.cwd(), options = {}) {
|
|
156
|
+
const repo = await GitRepository.discover(cwd);
|
|
157
|
+
const initialized = await initializeConfig(repo.root, options);
|
|
158
|
+
const store = new StateStore(repo.commonGitDir, initialized.config.stateDirectory, initialized.config.leases.lockTimeoutSeconds);
|
|
159
|
+
await store.initialize();
|
|
160
|
+
return { repoRoot: repo.root, configPath: initialized.path, created: initialized.created };
|
|
161
|
+
}
|
|
162
|
+
async registerTask(input) {
|
|
163
|
+
assertTaskId(input.id);
|
|
164
|
+
if (input.priority !== undefined && !Number.isInteger(input.priority)) {
|
|
165
|
+
throw new BrokerError("INVALID_TASK", "Task priority must be an integer.");
|
|
166
|
+
}
|
|
167
|
+
const baseSha = await this.repo.resolveCommit(input.base ?? this.config.baseRef);
|
|
168
|
+
const timestamp = now();
|
|
169
|
+
return await this.store.transaction((state, audit) => {
|
|
170
|
+
if (Object.hasOwn(state.tasks, input.id))
|
|
171
|
+
throw new BrokerError("TASK_EXISTS", `Task already exists: ${input.id}`);
|
|
172
|
+
const task = {
|
|
173
|
+
id: input.id,
|
|
174
|
+
...(input.title ? { title: input.title } : {}),
|
|
175
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
176
|
+
status: "registered",
|
|
177
|
+
priority: input.priority ?? 0,
|
|
178
|
+
baseSha,
|
|
179
|
+
expectedPaths: input.expectedPaths ?? [],
|
|
180
|
+
actualPaths: [],
|
|
181
|
+
dependsOn: [...new Set(input.dependsOn ?? [])],
|
|
182
|
+
commits: [],
|
|
183
|
+
...(input.worktree ? { worktree: path.resolve(input.worktree) } : {}),
|
|
184
|
+
warnings: [],
|
|
185
|
+
validations: [],
|
|
186
|
+
createdAt: timestamp,
|
|
187
|
+
updatedAt: timestamp,
|
|
188
|
+
};
|
|
189
|
+
state.tasks[task.id] = task;
|
|
190
|
+
audit("task.registered", { ...(input.agent ? { actor: input.agent } : {}), taskId: task.id });
|
|
191
|
+
return structuredClone(task);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async claimTask(input) {
|
|
195
|
+
assertTaskId(input.id);
|
|
196
|
+
if (input.priority !== undefined && !Number.isInteger(input.priority)) {
|
|
197
|
+
throw new BrokerError("INVALID_TASK", "Task priority must be an integer.");
|
|
198
|
+
}
|
|
199
|
+
const token = createToken();
|
|
200
|
+
const timestamp = now();
|
|
201
|
+
const expiresAt = new Date(Date.now() + this.config.leases.ttlSeconds * 1_000).toISOString();
|
|
202
|
+
const defaultBase = await this.repo.resolveCommit(input.base ?? this.config.baseRef);
|
|
203
|
+
const claimed = await this.store.transaction((state, audit) => {
|
|
204
|
+
let task = Object.hasOwn(state.tasks, input.id) ? state.tasks[input.id] : undefined;
|
|
205
|
+
if (!task) {
|
|
206
|
+
task = {
|
|
207
|
+
id: input.id,
|
|
208
|
+
...(input.title ? { title: input.title } : {}),
|
|
209
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
210
|
+
status: "registered",
|
|
211
|
+
priority: input.priority ?? 0,
|
|
212
|
+
baseSha: defaultBase,
|
|
213
|
+
expectedPaths: input.expectedPaths ?? [],
|
|
214
|
+
actualPaths: [],
|
|
215
|
+
dependsOn: [...new Set(input.dependsOn ?? [])],
|
|
216
|
+
commits: [],
|
|
217
|
+
...(input.worktree ? { worktree: path.resolve(input.worktree) } : {}),
|
|
218
|
+
warnings: [],
|
|
219
|
+
validations: [],
|
|
220
|
+
createdAt: timestamp,
|
|
221
|
+
updatedAt: timestamp,
|
|
222
|
+
};
|
|
223
|
+
state.tasks[input.id] = task;
|
|
224
|
+
audit("task.registered", { actor: input.holder, taskId: task.id });
|
|
225
|
+
}
|
|
226
|
+
else if (!new Set(["registered", "claimed", "failed"]).has(task.status)) {
|
|
227
|
+
throw new BrokerError("TASK_NOT_CLAIMABLE", `Task ${task.id} cannot be claimed while ${task.status}.`);
|
|
228
|
+
}
|
|
229
|
+
if (task.lease && !leaseExpired(task)) {
|
|
230
|
+
throw new BrokerError("LEASE_CONFLICT", `Task ${task.id} is already leased by ${task.lease.holder}.`);
|
|
231
|
+
}
|
|
232
|
+
const expectedPaths = input.expectedPaths ?? task.expectedPaths;
|
|
233
|
+
if (expectedPaths.length === 0) {
|
|
234
|
+
throw new BrokerError("PATHS_REQUIRED", "At least one expected path pattern is required to claim a task.");
|
|
235
|
+
}
|
|
236
|
+
assertNoLeaseConflict(state, task.id, expectedPaths, this.config.leases.serializedPatterns);
|
|
237
|
+
task.expectedPaths = [...new Set(expectedPaths)];
|
|
238
|
+
task.dependsOn = [...new Set(input.dependsOn ?? task.dependsOn)];
|
|
239
|
+
task.priority = input.priority ?? task.priority;
|
|
240
|
+
if (input.title)
|
|
241
|
+
task.title = input.title;
|
|
242
|
+
if (input.agent)
|
|
243
|
+
task.agent = input.agent;
|
|
244
|
+
if (input.worktree)
|
|
245
|
+
task.worktree = path.resolve(input.worktree);
|
|
246
|
+
task.status = "claimed";
|
|
247
|
+
task.lease = {
|
|
248
|
+
tokenHash: hashToken(token),
|
|
249
|
+
holder: input.holder,
|
|
250
|
+
acquiredAt: timestamp,
|
|
251
|
+
heartbeatAt: timestamp,
|
|
252
|
+
expiresAt,
|
|
253
|
+
};
|
|
254
|
+
task.updatedAt = timestamp;
|
|
255
|
+
delete task.lastError;
|
|
256
|
+
audit("task.claimed", { actor: input.holder, taskId: task.id, details: { expiresAt } });
|
|
257
|
+
return { task: structuredClone(task), token };
|
|
258
|
+
});
|
|
259
|
+
if (input.storeToken === false)
|
|
260
|
+
return claimed;
|
|
261
|
+
// A token written outside the default location is the caller's to supply again later; the
|
|
262
|
+
// automatic lookup only knows the default path.
|
|
263
|
+
const target = input.tokenFile ? path.resolve(input.tokenFile) : this.store.tokenPath(claimed.task.id);
|
|
264
|
+
const tokenPath = await this.store.writeToken(claimed.task.id, token, target);
|
|
265
|
+
return { ...claimed, tokenPath };
|
|
266
|
+
}
|
|
267
|
+
async heartbeat(taskId, token) {
|
|
268
|
+
return await this.store.transaction((state, audit) => {
|
|
269
|
+
const task = requireTask(state, taskId);
|
|
270
|
+
verifyLease(task, token);
|
|
271
|
+
const lease = task.lease;
|
|
272
|
+
if (!lease)
|
|
273
|
+
throw new BrokerError("LEASE_REQUIRED", `Task ${task.id} does not have an active lease.`);
|
|
274
|
+
const timestamp = now();
|
|
275
|
+
task.lease = {
|
|
276
|
+
...lease,
|
|
277
|
+
heartbeatAt: timestamp,
|
|
278
|
+
expiresAt: new Date(Date.now() + this.config.leases.ttlSeconds * 1_000).toISOString(),
|
|
279
|
+
};
|
|
280
|
+
task.updatedAt = timestamp;
|
|
281
|
+
audit("task.heartbeat", { actor: task.lease.holder, taskId });
|
|
282
|
+
return structuredClone(task);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async extendTask(taskId, expectedPaths, token) {
|
|
286
|
+
if (expectedPaths.length === 0) {
|
|
287
|
+
throw new BrokerError("PATHS_REQUIRED", "At least one expected path pattern is required to extend a task.");
|
|
288
|
+
}
|
|
289
|
+
return await this.store.transaction((state, audit) => {
|
|
290
|
+
const task = requireTask(state, taskId);
|
|
291
|
+
verifyLease(task, token);
|
|
292
|
+
if (task.status !== "claimed") {
|
|
293
|
+
throw new BrokerError("TASK_NOT_CLAIMABLE", `Task ${task.id} cannot be extended while ${task.status}.`);
|
|
294
|
+
}
|
|
295
|
+
const nextPaths = [...new Set([...task.expectedPaths, ...expectedPaths])];
|
|
296
|
+
assertNoLeaseConflict(state, task.id, nextPaths, this.config.leases.serializedPatterns);
|
|
297
|
+
task.expectedPaths = nextPaths;
|
|
298
|
+
task.updatedAt = now();
|
|
299
|
+
audit("task.extended", {
|
|
300
|
+
...(task.lease?.holder ? { actor: task.lease.holder } : {}),
|
|
301
|
+
taskId,
|
|
302
|
+
details: { expectedPaths: nextPaths },
|
|
303
|
+
});
|
|
304
|
+
return structuredClone(task);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async releaseTask(taskId, token, options = {}) {
|
|
308
|
+
const released = await this.store.transaction((state, audit) => {
|
|
309
|
+
const task = requireTask(state, taskId);
|
|
310
|
+
// A lease token is shown once. When the holder is gone and its token with it, the integration
|
|
311
|
+
// owner still needs a way to reclaim the scope, so a forced release skips verification and
|
|
312
|
+
// records that it did.
|
|
313
|
+
if (!options.force) {
|
|
314
|
+
if (!token)
|
|
315
|
+
throw new BrokerError("LEASE_TOKEN", `A lease token is required to release task ${taskId}.`);
|
|
316
|
+
verifyLease(task, token);
|
|
317
|
+
}
|
|
318
|
+
const actor = task.lease?.holder;
|
|
319
|
+
delete task.lease;
|
|
320
|
+
if (task.status === "claimed" || task.status === "failed")
|
|
321
|
+
task.status = "registered";
|
|
322
|
+
task.updatedAt = now();
|
|
323
|
+
audit("task.released", {
|
|
324
|
+
...(actor ? { actor } : {}),
|
|
325
|
+
taskId,
|
|
326
|
+
...(options.force ? { details: { forced: true } } : {}),
|
|
327
|
+
});
|
|
328
|
+
return structuredClone(task);
|
|
329
|
+
});
|
|
330
|
+
await this.store.deleteToken(taskId);
|
|
331
|
+
return released;
|
|
332
|
+
}
|
|
333
|
+
async cancelTask(taskId, token, options = {}) {
|
|
334
|
+
const cancelled = await this.store.transaction((state, audit) => {
|
|
335
|
+
const task = requireTask(state, taskId);
|
|
336
|
+
if (task.lease && !options.force) {
|
|
337
|
+
if (!token)
|
|
338
|
+
throw new BrokerError("LEASE_TOKEN", `A lease token is required to cancel task ${taskId}.`);
|
|
339
|
+
verifyLease(task, token);
|
|
340
|
+
}
|
|
341
|
+
if (["batched", "published", "merged"].includes(task.status)) {
|
|
342
|
+
throw new BrokerError("TASK_NOT_CANCELLABLE", `Task ${taskId} cannot be cancelled while ${task.status}.`);
|
|
343
|
+
}
|
|
344
|
+
const revokedFrom = options.force && task.lease ? task.lease.holder : undefined;
|
|
345
|
+
task.status = "cancelled";
|
|
346
|
+
delete task.lease;
|
|
347
|
+
task.updatedAt = now();
|
|
348
|
+
audit("task.cancelled", {
|
|
349
|
+
taskId,
|
|
350
|
+
...(revokedFrom ? { details: { forced: true, revokedLeaseFrom: revokedFrom } } : {}),
|
|
351
|
+
});
|
|
352
|
+
return structuredClone(task);
|
|
353
|
+
});
|
|
354
|
+
await this.store.deleteToken(taskId);
|
|
355
|
+
return cancelled;
|
|
356
|
+
}
|
|
357
|
+
async retryTask(taskId, token) {
|
|
358
|
+
return await this.store.transaction((state, audit) => {
|
|
359
|
+
const task = requireTask(state, taskId);
|
|
360
|
+
if (task.status !== "failed") {
|
|
361
|
+
throw new BrokerError("TASK_NOT_RETRYABLE", `Task ${taskId} cannot be retried while ${task.status}.`);
|
|
362
|
+
}
|
|
363
|
+
if (task.lease && !leaseExpired(task)) {
|
|
364
|
+
if (!token)
|
|
365
|
+
throw new BrokerError("LEASE_TOKEN", `A lease token is required to retry task ${taskId}.`);
|
|
366
|
+
verifyLease(task, token);
|
|
367
|
+
}
|
|
368
|
+
task.status = "submitted";
|
|
369
|
+
delete task.batchId;
|
|
370
|
+
delete task.lastError;
|
|
371
|
+
// An explicit human retry restarts the automatic attempt budget.
|
|
372
|
+
delete task.attempts;
|
|
373
|
+
task.updatedAt = now();
|
|
374
|
+
audit("task.retried", { taskId });
|
|
375
|
+
return structuredClone(task);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async submitTask(taskId, commits, token, options = {}) {
|
|
379
|
+
assertTaskId(taskId);
|
|
380
|
+
const snapshot = requireTask(await this.store.read(), taskId);
|
|
381
|
+
verifyLease(snapshot, token);
|
|
382
|
+
if (options.sinceBase) {
|
|
383
|
+
const worktree = snapshot.worktree ?? this.repo.root;
|
|
384
|
+
commits = await this.repo.commitsSinceBase(worktree, snapshot.baseSha);
|
|
385
|
+
if (commits.length === 0) {
|
|
386
|
+
throw new BrokerError("COMMITS_REQUIRED", `Task ${taskId} has no commits after its recorded base ${snapshot.baseSha} in ${worktree}.`, { worktree, baseSha: snapshot.baseSha });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (commits.length === 0)
|
|
390
|
+
throw new BrokerError("COMMITS_REQUIRED", "At least one commit is required.");
|
|
391
|
+
if (this.config.policies.requireCleanWorktree && snapshot.worktree) {
|
|
392
|
+
if (!(await this.repo.isClean(snapshot.worktree))) {
|
|
393
|
+
throw new BrokerError("DIRTY_WORKTREE", `Task worktree is not clean: ${snapshot.worktree}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const resolvedCommits = [];
|
|
397
|
+
for (const commit of commits)
|
|
398
|
+
resolvedCommits.push(await this.repo.resolveCommit(commit));
|
|
399
|
+
if (new Set(resolvedCommits).size !== resolvedCommits.length) {
|
|
400
|
+
throw new BrokerError("DUPLICATE_COMMIT", `Task ${taskId} submitted the same commit more than once.`);
|
|
401
|
+
}
|
|
402
|
+
for (const commit of resolvedCommits) {
|
|
403
|
+
if ((await this.repo.parentCount(commit)) > 1) {
|
|
404
|
+
throw new BrokerError("MERGE_COMMIT", `Task ${taskId} submitted merge commit ${commit}. Submit focused linear commits instead.`);
|
|
405
|
+
}
|
|
406
|
+
if ((await this.repo.changedFiles(commit)).length === 0) {
|
|
407
|
+
throw new BrokerError("EMPTY_COMMIT", `Task ${taskId} submitted empty commit ${commit}.`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const actualPaths = await this.repo.changedFilesForCommits(resolvedCommits);
|
|
411
|
+
const outsideScope = unexpectedPaths(actualPaths, snapshot.expectedPaths);
|
|
412
|
+
if (outsideScope.length > 0 && this.config.policies.unexpectedPaths === "error") {
|
|
413
|
+
throw new BrokerError("UNEXPECTED_PATHS", `Task ${taskId} changed files outside its declared scope.`, {
|
|
414
|
+
unexpectedPaths: outsideScope,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const timestamp = now();
|
|
418
|
+
const task = await this.store.transaction((state, audit) => {
|
|
419
|
+
const current = requireTask(state, taskId);
|
|
420
|
+
verifyLease(current, token);
|
|
421
|
+
if (current.status !== "claimed" && current.status !== "failed") {
|
|
422
|
+
throw new BrokerError("TASK_NOT_SUBMITTABLE", `Task ${taskId} cannot be submitted while ${current.status}.`);
|
|
423
|
+
}
|
|
424
|
+
current.commits = resolvedCommits;
|
|
425
|
+
current.actualPaths = actualPaths;
|
|
426
|
+
current.warnings =
|
|
427
|
+
outsideScope.length > 0 && this.config.policies.unexpectedPaths === "warn"
|
|
428
|
+
? [`Changed outside expected paths: ${outsideScope.join(", ")}`]
|
|
429
|
+
: [];
|
|
430
|
+
if (current.dependsOn.includes(current.id)) {
|
|
431
|
+
throw new BrokerError("DEPENDENCY_CYCLE", `Task ${current.id} cannot depend on itself.`);
|
|
432
|
+
}
|
|
433
|
+
if (this.config.policies.requireDependencies) {
|
|
434
|
+
const unknownDependencies = current.dependsOn.filter((dependency) => !Object.hasOwn(state.tasks, dependency));
|
|
435
|
+
if (unknownDependencies.length > 0) {
|
|
436
|
+
throw new BrokerError("UNKNOWN_DEPENDENCY", `Task ${current.id} has unknown dependencies: ${unknownDependencies.join(", ")}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
current.status = "submitted";
|
|
440
|
+
current.submittedAt = timestamp;
|
|
441
|
+
current.updatedAt = timestamp;
|
|
442
|
+
delete current.lastError;
|
|
443
|
+
audit("task.submitted", {
|
|
444
|
+
...(current.lease?.holder ? { actor: current.lease.holder } : {}),
|
|
445
|
+
taskId,
|
|
446
|
+
details: { commits: resolvedCommits, actualPaths, warnings: current.warnings },
|
|
447
|
+
});
|
|
448
|
+
return structuredClone(current);
|
|
449
|
+
});
|
|
450
|
+
const receipt = {
|
|
451
|
+
version: 1,
|
|
452
|
+
taskId: task.id,
|
|
453
|
+
...(task.agent ? { agent: task.agent } : {}),
|
|
454
|
+
baseSha: task.baseSha,
|
|
455
|
+
commits: task.commits,
|
|
456
|
+
expectedPaths: task.expectedPaths,
|
|
457
|
+
actualPaths: task.actualPaths,
|
|
458
|
+
dependsOn: task.dependsOn,
|
|
459
|
+
...(task.worktree ? { worktree: task.worktree } : {}),
|
|
460
|
+
submittedAt: timestamp,
|
|
461
|
+
warnings: task.warnings,
|
|
462
|
+
};
|
|
463
|
+
const receiptPath = await this.store.writeReceipt(receipt);
|
|
464
|
+
return { task, receiptPath };
|
|
465
|
+
}
|
|
466
|
+
async state() {
|
|
467
|
+
return await this.store.read();
|
|
468
|
+
}
|
|
469
|
+
async task(taskId) {
|
|
470
|
+
return structuredClone(requireTask(await this.store.read(), taskId));
|
|
471
|
+
}
|
|
472
|
+
async plan(options = {}) {
|
|
473
|
+
if (options.maxTasks !== undefined && (!Number.isInteger(options.maxTasks) || options.maxTasks <= 0)) {
|
|
474
|
+
throw new BrokerError("INVALID_LIMIT", "maxTasks must be a positive integer.");
|
|
475
|
+
}
|
|
476
|
+
return scheduleTasks(await this.store.read(), this.config, options);
|
|
477
|
+
}
|
|
478
|
+
async integrate(options = {}) {
|
|
479
|
+
return await this.store.withIntegrationLock(async () => {
|
|
480
|
+
const plan = await this.plan({
|
|
481
|
+
...(options.taskIds ? { taskIds: options.taskIds } : {}),
|
|
482
|
+
...(options.maxTasks ? { maxTasks: options.maxTasks } : {}),
|
|
483
|
+
});
|
|
484
|
+
if (plan.selected.length === 0) {
|
|
485
|
+
throw new BrokerError("EMPTY_BATCH", "No submitted, dependency-ready tasks are available to integrate.", {
|
|
486
|
+
rejected: plan.rejected,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const id = batchId();
|
|
490
|
+
// Cut the batch from the current remote tip. Without this the branch is born behind the base
|
|
491
|
+
// branch as soon as anything else lands, which "require branches to be up to date" protection
|
|
492
|
+
// treats as unmergeable.
|
|
493
|
+
if (this.config.integration.refreshBase) {
|
|
494
|
+
await this.repo.fetchBranch(this.config.remote, this.config.baseBranch);
|
|
495
|
+
}
|
|
496
|
+
const baseSha = await this.repo.resolveCommit(this.config.baseRef);
|
|
497
|
+
const worktree = path.join(this.store.worktreesDirectory, id);
|
|
498
|
+
const batch = {
|
|
499
|
+
id,
|
|
500
|
+
status: "running",
|
|
501
|
+
taskIds: plan.selected.map((task) => task.id),
|
|
502
|
+
baseBranch: this.config.baseBranch,
|
|
503
|
+
baseSha,
|
|
504
|
+
worktree,
|
|
505
|
+
validations: [],
|
|
506
|
+
createdAt: now(),
|
|
507
|
+
};
|
|
508
|
+
await this.store.transaction((state, audit) => {
|
|
509
|
+
for (const selected of plan.selected) {
|
|
510
|
+
const task = requireTask(state, selected.id);
|
|
511
|
+
if (task.status !== "submitted") {
|
|
512
|
+
throw new BrokerError("TASK_CHANGED", `Task ${task.id} changed status before integration.`);
|
|
513
|
+
}
|
|
514
|
+
task.status = "integrating";
|
|
515
|
+
task.batchId = id;
|
|
516
|
+
task.updatedAt = now();
|
|
517
|
+
}
|
|
518
|
+
state.batches[id] = batch;
|
|
519
|
+
audit("batch.started", { batchId: id, details: { taskIds: batch.taskIds, baseSha } });
|
|
520
|
+
});
|
|
521
|
+
let worktreeAdded = false;
|
|
522
|
+
let keepWorktree = false;
|
|
523
|
+
// Set while work is attributable to a single task, so one bad commit fails that task instead
|
|
524
|
+
// of the whole batch. Cleared before authoritative validation, which indicts the batch.
|
|
525
|
+
let culpritTaskId;
|
|
526
|
+
try {
|
|
527
|
+
await this.repo.addDetachedWorktree(worktree, baseSha);
|
|
528
|
+
worktreeAdded = true;
|
|
529
|
+
for (const task of plan.selected) {
|
|
530
|
+
culpritTaskId = task.id;
|
|
531
|
+
for (const commit of task.commits) {
|
|
532
|
+
const picked = await this.repo.cherryPick(worktree, commit);
|
|
533
|
+
if (picked.exitCode !== 0) {
|
|
534
|
+
await this.repo.abortCherryPick(worktree);
|
|
535
|
+
throw new BrokerError("CHERRY_PICK_CONFLICT", `Commit ${commit} from task ${task.id} did not apply cleanly.`, {
|
|
536
|
+
taskId: task.id,
|
|
537
|
+
commit,
|
|
538
|
+
stdout: picked.stdout,
|
|
539
|
+
stderr: picked.stderr,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const headSha = await this.repo.currentHead(worktree);
|
|
544
|
+
const focused = await runValidators({
|
|
545
|
+
validators: this.config.validation.focused,
|
|
546
|
+
scope: "focused",
|
|
547
|
+
cwd: worktree,
|
|
548
|
+
taskId: task.id,
|
|
549
|
+
files: task.actualPaths,
|
|
550
|
+
baseSha,
|
|
551
|
+
headSha,
|
|
552
|
+
batchId: id,
|
|
553
|
+
...(this.config.validation.shell ? { shell: this.config.validation.shell } : {}),
|
|
554
|
+
});
|
|
555
|
+
batch.validations.push(...focused);
|
|
556
|
+
}
|
|
557
|
+
culpritTaskId = undefined;
|
|
558
|
+
let headSha = await this.repo.currentHead(worktree);
|
|
559
|
+
const allFiles = [...new Set(plan.selected.flatMap((task) => task.actualPaths))].sort();
|
|
560
|
+
const authoritative = await runValidators({
|
|
561
|
+
validators: this.config.validation.authoritative,
|
|
562
|
+
scope: "authoritative",
|
|
563
|
+
cwd: worktree,
|
|
564
|
+
files: allFiles,
|
|
565
|
+
baseSha,
|
|
566
|
+
headSha,
|
|
567
|
+
batchId: id,
|
|
568
|
+
...(this.config.validation.shell ? { shell: this.config.validation.shell } : {}),
|
|
569
|
+
});
|
|
570
|
+
batch.validations.push(...authoritative);
|
|
571
|
+
if (this.config.integration.history === "squash") {
|
|
572
|
+
headSha = await this.repo.squash(worktree, baseSha, `Integrate batch ${id}\n\n${plan.selected.map((task) => `Task: ${task.id}`).join("\n")}`);
|
|
573
|
+
}
|
|
574
|
+
const provenance = this.config.integration.provenance;
|
|
575
|
+
if (provenance?.enabled) {
|
|
576
|
+
const integratedHeadSha = headSha;
|
|
577
|
+
const integratedPaths = await this.repo.changedFilesBetween(baseSha, integratedHeadSha);
|
|
578
|
+
const relativePath = provenancePath(provenance.directory, id);
|
|
579
|
+
const record = buildBatchProvenance({
|
|
580
|
+
batch,
|
|
581
|
+
tasks: plan.selected,
|
|
582
|
+
integratedHeadSha,
|
|
583
|
+
integratedPaths,
|
|
584
|
+
history: this.config.integration.history,
|
|
585
|
+
});
|
|
586
|
+
headSha = await this.repo.commitGeneratedFile(worktree, relativePath, `${JSON.stringify(record, null, 2)}\n`, `Record Merge Broker batch ${id}`);
|
|
587
|
+
batch.integratedHeadSha = integratedHeadSha;
|
|
588
|
+
batch.provenancePath = relativePath;
|
|
589
|
+
}
|
|
590
|
+
batch.headSha = headSha;
|
|
591
|
+
batch.finishedAt = now();
|
|
592
|
+
if (options.dryRun) {
|
|
593
|
+
batch.status = "verified";
|
|
594
|
+
await this.store.transaction((state, audit) => {
|
|
595
|
+
state.batches[id] = structuredClone(batch);
|
|
596
|
+
for (const taskId of batch.taskIds) {
|
|
597
|
+
const task = requireTask(state, taskId);
|
|
598
|
+
task.status = "submitted";
|
|
599
|
+
delete task.batchId;
|
|
600
|
+
task.updatedAt = now();
|
|
601
|
+
}
|
|
602
|
+
audit("batch.verified", { batchId: id, details: { taskIds: batch.taskIds, headSha } });
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const branchName = cleanBranchFragment(`${this.config.integration.branchPrefix}${id}`);
|
|
607
|
+
await this.repo.createBranch(branchName, headSha);
|
|
608
|
+
batch.branchName = branchName;
|
|
609
|
+
batch.status = "prepared";
|
|
610
|
+
await this.store.transaction((state, audit) => {
|
|
611
|
+
state.batches[id] = structuredClone(batch);
|
|
612
|
+
for (const taskId of batch.taskIds) {
|
|
613
|
+
const task = requireTask(state, taskId);
|
|
614
|
+
task.status = "batched";
|
|
615
|
+
task.batchedAt = now();
|
|
616
|
+
delete task.lease;
|
|
617
|
+
task.validations = batch.validations.filter((result) => !result.taskId || result.taskId === taskId);
|
|
618
|
+
task.updatedAt = now();
|
|
619
|
+
}
|
|
620
|
+
audit("batch.prepared", {
|
|
621
|
+
batchId: id,
|
|
622
|
+
details: { taskIds: batch.taskIds, branchName, headSha },
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
// Batching ends the lease, so any token held for it is now dead weight.
|
|
626
|
+
for (const taskId of batch.taskIds)
|
|
627
|
+
await this.store.deleteToken(taskId);
|
|
628
|
+
}
|
|
629
|
+
await this.store.writeBatchManifest(id, {
|
|
630
|
+
batch,
|
|
631
|
+
tasks: plan.selected.map((task) => ({
|
|
632
|
+
id: task.id,
|
|
633
|
+
commits: task.commits,
|
|
634
|
+
actualPaths: task.actualPaths,
|
|
635
|
+
dependsOn: task.dependsOn,
|
|
636
|
+
})),
|
|
637
|
+
rejected: plan.rejected,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
keepWorktree = this.config.integration.keepFailedWorktrees;
|
|
642
|
+
if (batch.branchName) {
|
|
643
|
+
await this.repo.deleteBranch(batch.branchName);
|
|
644
|
+
delete batch.branchName;
|
|
645
|
+
}
|
|
646
|
+
const failureResults = validationResultsFromError(error);
|
|
647
|
+
batch.validations.push(...failureResults.filter((item) => !batch.validations.includes(item)));
|
|
648
|
+
batch.status = "failed";
|
|
649
|
+
batch.error = errorMessage(error);
|
|
650
|
+
batch.finishedAt = now();
|
|
651
|
+
if (keepWorktree)
|
|
652
|
+
batch.worktree = worktree;
|
|
653
|
+
else
|
|
654
|
+
delete batch.worktree;
|
|
655
|
+
const requeued = [];
|
|
656
|
+
await this.store.transaction((state, audit) => {
|
|
657
|
+
state.batches[id] = structuredClone(batch);
|
|
658
|
+
for (const taskId of batch.taskIds) {
|
|
659
|
+
const task = requireTask(state, taskId);
|
|
660
|
+
delete task.batchId;
|
|
661
|
+
task.updatedAt = now();
|
|
662
|
+
// Only the task that actually broke stays failed. Its batch-mates are returned to the
|
|
663
|
+
// queue so a single conflicting commit cannot stall every other agent's work.
|
|
664
|
+
if (culpritTaskId && taskId !== culpritTaskId && (task.attempts ?? 0) + 1 < this.config.integration.maxAttempts) {
|
|
665
|
+
task.attempts = (task.attempts ?? 0) + 1;
|
|
666
|
+
task.status = "submitted";
|
|
667
|
+
requeued.push(taskId);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
task.status = "failed";
|
|
671
|
+
if (batch.error)
|
|
672
|
+
task.lastError = batch.error;
|
|
673
|
+
}
|
|
674
|
+
audit("batch.failed", {
|
|
675
|
+
batchId: id,
|
|
676
|
+
details: { error: batch.error, ...(culpritTaskId ? { culpritTaskId } : {}), requeued },
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
await this.store.writeBatchManifest(id, { batch, error: batch.error });
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
if (worktreeAdded && !keepWorktree)
|
|
684
|
+
await this.repo.removeWorktree(worktree);
|
|
685
|
+
}
|
|
686
|
+
if (!options.dryRun && options.publish) {
|
|
687
|
+
await this.publishBatch(id);
|
|
688
|
+
}
|
|
689
|
+
const finalBatch = requireBatch(await this.store.read(), id);
|
|
690
|
+
return {
|
|
691
|
+
batch: structuredClone(finalBatch),
|
|
692
|
+
selected: batch.taskIds,
|
|
693
|
+
rejected: plan.rejected,
|
|
694
|
+
dryRun: options.dryRun ?? false,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
async publishBatch(id) {
|
|
699
|
+
const state = await this.store.read();
|
|
700
|
+
const batch = requireBatch(state, id);
|
|
701
|
+
if (batch.status !== "prepared") {
|
|
702
|
+
throw new BrokerError("BATCH_NOT_PUBLISHABLE", `Batch ${id} cannot be published while ${batch.status}.`);
|
|
703
|
+
}
|
|
704
|
+
const tasks = batch.taskIds.map((taskId) => requireTask(state, taskId));
|
|
705
|
+
let publication;
|
|
706
|
+
try {
|
|
707
|
+
publication = await publishPreparedBatch({ repo: this.repo, config: this.config, batch, tasks });
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
await this.store.transaction((current, audit) => {
|
|
711
|
+
const stored = Object.hasOwn(current.batches, id) ? current.batches[id] : undefined;
|
|
712
|
+
if (stored)
|
|
713
|
+
stored.error = errorMessage(error);
|
|
714
|
+
audit("batch.publish_failed", { batchId: id, details: { error: errorMessage(error) } });
|
|
715
|
+
});
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
return await this.store.transaction((current, audit) => {
|
|
719
|
+
const stored = requireBatch(current, id);
|
|
720
|
+
stored.status = "published";
|
|
721
|
+
stored.publishedAt = now();
|
|
722
|
+
delete stored.error;
|
|
723
|
+
if (publication.pullRequestUrl)
|
|
724
|
+
stored.pullRequestUrl = publication.pullRequestUrl;
|
|
725
|
+
if (publication.autoMergeEnabled !== undefined)
|
|
726
|
+
stored.autoMergeEnabled = publication.autoMergeEnabled;
|
|
727
|
+
for (const taskId of stored.taskIds) {
|
|
728
|
+
const task = requireTask(current, taskId);
|
|
729
|
+
task.status = "published";
|
|
730
|
+
task.publishedAt = stored.publishedAt;
|
|
731
|
+
task.updatedAt = now();
|
|
732
|
+
}
|
|
733
|
+
audit("batch.published", {
|
|
734
|
+
batchId: id,
|
|
735
|
+
details: {
|
|
736
|
+
branchName: stored.branchName,
|
|
737
|
+
pullRequestUrl: stored.pullRequestUrl,
|
|
738
|
+
autoMergeEnabled: stored.autoMergeEnabled,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
return structuredClone(stored);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async syncBatch(id) {
|
|
745
|
+
const state = await this.store.read();
|
|
746
|
+
const batch = requireBatch(state, id);
|
|
747
|
+
if (batch.status === "merged")
|
|
748
|
+
return structuredClone(batch);
|
|
749
|
+
let mergedAt;
|
|
750
|
+
let mergeCommitSha;
|
|
751
|
+
if (batch.pullRequestUrl) {
|
|
752
|
+
const pullRequest = await inspectPullRequest(this.repo.root, batch.pullRequestUrl);
|
|
753
|
+
// A pull request closed without merging means the work was rejected, not completed. Leaving
|
|
754
|
+
// the batch "published" strands every task in it forever and reads like success.
|
|
755
|
+
if (pullRequest.state === "CLOSED") {
|
|
756
|
+
return await this.closeBatch(id, `Pull request ${batch.pullRequestUrl} was closed without merging.`);
|
|
757
|
+
}
|
|
758
|
+
if (pullRequest.state !== "MERGED")
|
|
759
|
+
return structuredClone(batch);
|
|
760
|
+
mergedAt = pullRequest.mergedAt ?? now();
|
|
761
|
+
mergeCommitSha = pullRequest.mergeCommitSha;
|
|
762
|
+
}
|
|
763
|
+
else if (batch.headSha) {
|
|
764
|
+
await this.repo.git(["fetch", this.config.remote, this.config.baseBranch]);
|
|
765
|
+
const check = await this.repo.git(["merge-base", "--is-ancestor", batch.headSha, `${this.config.remote}/${this.config.baseBranch}`], this.repo.root, true);
|
|
766
|
+
if (check.exitCode !== 0)
|
|
767
|
+
return structuredClone(batch);
|
|
768
|
+
mergedAt = now();
|
|
769
|
+
mergeCommitSha = batch.headSha;
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
throw new BrokerError("BATCH_NOT_SYNCABLE", `Batch ${id} has no PR URL or head commit.`);
|
|
773
|
+
}
|
|
774
|
+
return await this.markBatchMerged(id, mergedAt, mergeCommitSha);
|
|
775
|
+
}
|
|
776
|
+
async syncPublishedBatches() {
|
|
777
|
+
const state = await this.store.read();
|
|
778
|
+
const published = Object.values(state.batches).filter((batch) => batch.status === "published");
|
|
779
|
+
const synced = [];
|
|
780
|
+
const closed = [];
|
|
781
|
+
const unchanged = [];
|
|
782
|
+
const errors = [];
|
|
783
|
+
for (const batch of published) {
|
|
784
|
+
try {
|
|
785
|
+
const result = await this.syncBatch(batch.id);
|
|
786
|
+
if (result.status === "merged")
|
|
787
|
+
synced.push(result);
|
|
788
|
+
else if (result.status === "closed")
|
|
789
|
+
closed.push(result);
|
|
790
|
+
else
|
|
791
|
+
unchanged.push(result);
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
errors.push({ batchId: batch.id, error: errorMessage(error) });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return { synced, closed, unchanged, errors };
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Records that a published batch will never merge and returns its tasks to the queue, so the work
|
|
801
|
+
* is re-planned against a fresh base instead of being silently lost.
|
|
802
|
+
*/
|
|
803
|
+
async closeBatch(id, reason) {
|
|
804
|
+
return await this.store.transaction((state, audit) => {
|
|
805
|
+
const batch = requireBatch(state, id);
|
|
806
|
+
batch.status = "closed";
|
|
807
|
+
batch.error = reason;
|
|
808
|
+
batch.closedAt = now();
|
|
809
|
+
batch.finishedAt = batch.closedAt;
|
|
810
|
+
const requeued = [];
|
|
811
|
+
const exhausted = [];
|
|
812
|
+
for (const taskId of batch.taskIds) {
|
|
813
|
+
const task = requireTask(state, taskId);
|
|
814
|
+
if (task.status === "merged")
|
|
815
|
+
continue;
|
|
816
|
+
delete task.batchId;
|
|
817
|
+
delete task.publishedAt;
|
|
818
|
+
task.lastError = reason;
|
|
819
|
+
task.attempts = (task.attempts ?? 0) + 1;
|
|
820
|
+
task.updatedAt = now();
|
|
821
|
+
if (task.attempts < this.config.integration.maxAttempts && task.commits.length > 0) {
|
|
822
|
+
task.status = "submitted";
|
|
823
|
+
requeued.push(taskId);
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
task.status = "failed";
|
|
827
|
+
exhausted.push(taskId);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
audit("batch.closed", { batchId: id, details: { reason, requeued, exhausted } });
|
|
831
|
+
return structuredClone(batch);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
async markBatchMerged(id, mergedAt = now(), mergeCommitSha) {
|
|
835
|
+
return await this.store.transaction((state, audit) => {
|
|
836
|
+
const batch = requireBatch(state, id);
|
|
837
|
+
// "closed" is included so a pull request that was reopened and merged can still be reconciled
|
|
838
|
+
// with "batch complete".
|
|
839
|
+
if (!new Set(["prepared", "published", "closed", "merged"]).has(batch.status)) {
|
|
840
|
+
throw new BrokerError("BATCH_NOT_MERGED", `Batch ${id} cannot be completed while ${batch.status}.`);
|
|
841
|
+
}
|
|
842
|
+
batch.status = "merged";
|
|
843
|
+
batch.finishedAt = mergedAt;
|
|
844
|
+
for (const taskId of batch.taskIds) {
|
|
845
|
+
const task = requireTask(state, taskId);
|
|
846
|
+
task.status = "merged";
|
|
847
|
+
task.mergedAt = mergedAt;
|
|
848
|
+
task.updatedAt = now();
|
|
849
|
+
}
|
|
850
|
+
audit("batch.merged", { batchId: id, details: { mergedAt, mergeCommitSha } });
|
|
851
|
+
return structuredClone(batch);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
async auditWorktrees() {
|
|
855
|
+
const [state, worktrees] = await Promise.all([this.store.read(), this.repo.listWorktrees()]);
|
|
856
|
+
const staleLeases = Object.values(state.tasks)
|
|
857
|
+
.filter((task) => task.lease && leaseExpired(task))
|
|
858
|
+
.map((task) => task.id);
|
|
859
|
+
const registeredPaths = new Map();
|
|
860
|
+
for (const task of Object.values(state.tasks)) {
|
|
861
|
+
if (!task.worktree)
|
|
862
|
+
continue;
|
|
863
|
+
const resolved = path.resolve(task.worktree);
|
|
864
|
+
registeredPaths.set(resolved, [...(registeredPaths.get(resolved) ?? []), task.id]);
|
|
865
|
+
}
|
|
866
|
+
const details = await Promise.all(worktrees.map(async (worktree) => ({
|
|
867
|
+
path: worktree.path,
|
|
868
|
+
...(worktree.branch ? { branch: worktree.branch } : {}),
|
|
869
|
+
registeredTaskIds: registeredPaths.get(path.resolve(worktree.path)) ?? [],
|
|
870
|
+
clean: await this.repo.isClean(worktree.path),
|
|
871
|
+
})));
|
|
872
|
+
const primaryWorktree = worktrees[0]?.path ? path.resolve(worktrees[0].path) : undefined;
|
|
873
|
+
return {
|
|
874
|
+
worktrees: details,
|
|
875
|
+
staleLeases,
|
|
876
|
+
unregisteredWorktrees: details
|
|
877
|
+
.filter((item) => path.resolve(item.path) !== this.repo.root &&
|
|
878
|
+
path.resolve(item.path) !== primaryWorktree &&
|
|
879
|
+
item.registeredTaskIds.length === 0)
|
|
880
|
+
.map((item) => item.path),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Retires completed records so `state.json` stops growing without bound. The file is rewritten in
|
|
885
|
+
* full on every transaction, including heartbeats, so an unbounded history makes routine
|
|
886
|
+
* operations progressively slower.
|
|
887
|
+
*/
|
|
888
|
+
async prune(options = {}) {
|
|
889
|
+
const days = options.olderThanDays ?? 30;
|
|
890
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
891
|
+
throw new BrokerError("INVALID_LIMIT", "olderThanDays must be a non-negative number.");
|
|
892
|
+
}
|
|
893
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1_000;
|
|
894
|
+
const cutoff = new Date(cutoffMs).toISOString();
|
|
895
|
+
if (options.dryRun) {
|
|
896
|
+
const selection = selectPrunable(await this.store.read(), cutoffMs);
|
|
897
|
+
return { ...selection, cutoff, dryRun: true };
|
|
898
|
+
}
|
|
899
|
+
const pruned = await this.store.transaction(async (state, audit) => {
|
|
900
|
+
const selection = selectPrunable(state, cutoffMs);
|
|
901
|
+
if (selection.tasks.length === 0 && selection.batches.length === 0) {
|
|
902
|
+
return { ...selection, cutoff, dryRun: false };
|
|
903
|
+
}
|
|
904
|
+
const archivePath = await this.store.archive("state", {
|
|
905
|
+
archivedAt: now(),
|
|
906
|
+
cutoff,
|
|
907
|
+
tasks: Object.fromEntries(selection.tasks.map((id) => [id, state.tasks[id]])),
|
|
908
|
+
batches: Object.fromEntries(selection.batches.map((id) => [id, state.batches[id]])),
|
|
909
|
+
});
|
|
910
|
+
for (const id of selection.tasks)
|
|
911
|
+
delete state.tasks[id];
|
|
912
|
+
for (const id of selection.batches)
|
|
913
|
+
delete state.batches[id];
|
|
914
|
+
audit("state.pruned", {
|
|
915
|
+
details: {
|
|
916
|
+
cutoff,
|
|
917
|
+
tasks: selection.tasks,
|
|
918
|
+
batches: selection.batches,
|
|
919
|
+
archivePath,
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
return { ...selection, cutoff, archivePath, dryRun: false };
|
|
923
|
+
});
|
|
924
|
+
for (const taskId of pruned.tasks)
|
|
925
|
+
await this.store.deleteToken(taskId);
|
|
926
|
+
return pruned;
|
|
927
|
+
}
|
|
928
|
+
async installHooks(options = {}) {
|
|
929
|
+
if (options.uninstall)
|
|
930
|
+
return await uninstallHooks(this.repo);
|
|
931
|
+
return await installHooks({
|
|
932
|
+
repo: this.repo,
|
|
933
|
+
branchPrefix: this.config.integration.branchPrefix,
|
|
934
|
+
force: options.force ?? false,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
async inspectLocks() {
|
|
938
|
+
return await Promise.all(["state", "integration"].map(async (name) => await this.store.inspectLock(name)));
|
|
939
|
+
}
|
|
940
|
+
async releaseLock(name, options = {}) {
|
|
941
|
+
if (name !== "state" && name !== "integration") {
|
|
942
|
+
throw new BrokerError("UNKNOWN_LOCK", `Unknown lock: ${name}. Expected "state" or "integration".`);
|
|
943
|
+
}
|
|
944
|
+
const released = await this.store.releaseLock(name, options);
|
|
945
|
+
await this.store.transaction((_state, audit) => {
|
|
946
|
+
audit("lock.released", { details: { lock: name, forced: options.force ?? false, previous: released.owner } });
|
|
947
|
+
});
|
|
948
|
+
return released;
|
|
949
|
+
}
|
|
950
|
+
async metrics() {
|
|
951
|
+
const state = await this.store.read();
|
|
952
|
+
const tasks = Object.values(state.tasks);
|
|
953
|
+
const batches = Object.values(state.batches);
|
|
954
|
+
const statusCounts = Object.fromEntries([...new Set(tasks.map((task) => task.status))]
|
|
955
|
+
.sort()
|
|
956
|
+
.map((status) => [status, tasks.filter((task) => task.status === status).length]));
|
|
957
|
+
const mergedLeadTimes = tasks
|
|
958
|
+
.filter((task) => task.submittedAt && task.mergedAt)
|
|
959
|
+
.map((task) => Date.parse(task.mergedAt ?? "") - Date.parse(task.submittedAt ?? ""))
|
|
960
|
+
.filter((duration) => Number.isFinite(duration) && duration >= 0);
|
|
961
|
+
const validations = batches.flatMap((batch) => batch.validations);
|
|
962
|
+
return {
|
|
963
|
+
generatedAt: now(),
|
|
964
|
+
tasks: {
|
|
965
|
+
total: tasks.length,
|
|
966
|
+
byStatus: statusCounts,
|
|
967
|
+
merged: tasks.filter((task) => task.status === "merged").length,
|
|
968
|
+
averageSubmitToMergeMs: mergedLeadTimes.length > 0
|
|
969
|
+
? Math.round(mergedLeadTimes.reduce((sum, duration) => sum + duration, 0) / mergedLeadTimes.length)
|
|
970
|
+
: null,
|
|
971
|
+
},
|
|
972
|
+
batches: {
|
|
973
|
+
total: batches.length,
|
|
974
|
+
merged: batches.filter((batch) => batch.status === "merged").length,
|
|
975
|
+
closed: batches.filter((batch) => batch.status === "closed").length,
|
|
976
|
+
failed: batches.filter((batch) => batch.status === "failed").length,
|
|
977
|
+
averageTaskCount: batches.length > 0
|
|
978
|
+
? Number((batches.reduce((sum, batch) => sum + batch.taskIds.length, 0) / batches.length).toFixed(2))
|
|
979
|
+
: 0,
|
|
980
|
+
},
|
|
981
|
+
validation: {
|
|
982
|
+
runs: validations.length,
|
|
983
|
+
failures: validations.filter((validation) => validation.exitCode !== 0).length,
|
|
984
|
+
durationMs: validations.reduce((sum, validation) => sum + validation.durationMs, 0),
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
async doctor() {
|
|
989
|
+
const [baseSha, clean, worktrees, locks] = await Promise.all([
|
|
990
|
+
this.repo.resolveCommit(this.config.baseRef),
|
|
991
|
+
this.repo.isClean(),
|
|
992
|
+
this.repo.listWorktrees(),
|
|
993
|
+
this.inspectLocks(),
|
|
994
|
+
]);
|
|
995
|
+
const warnings = [];
|
|
996
|
+
if (this.config.validation.focused.length === 0 && this.config.validation.authoritative.length === 0) {
|
|
997
|
+
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.");
|
|
998
|
+
}
|
|
999
|
+
for (const lock of locks) {
|
|
1000
|
+
if (!lock.held)
|
|
1001
|
+
continue;
|
|
1002
|
+
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" : ""}.`);
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
ok: true,
|
|
1006
|
+
warnings,
|
|
1007
|
+
locks,
|
|
1008
|
+
repository: this.repo.root,
|
|
1009
|
+
gitDirectory: this.repo.commonGitDir,
|
|
1010
|
+
stateDirectory: this.store.directory,
|
|
1011
|
+
config: path.join(this.repo.root, ".merge-broker", "config.json"),
|
|
1012
|
+
baseBranch: this.config.baseBranch,
|
|
1013
|
+
baseRef: this.config.baseRef,
|
|
1014
|
+
baseSha,
|
|
1015
|
+
worktreeClean: clean,
|
|
1016
|
+
worktreeCount: worktrees.length,
|
|
1017
|
+
publishMode: this.config.publish.mode,
|
|
1018
|
+
focusedValidators: this.config.validation.focused.map((validator) => validator.name),
|
|
1019
|
+
authoritativeValidators: this.config.validation.authoritative.map((validator) => validator.name),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
//# sourceMappingURL=broker.js.map
|