@bivy/bivy 0.16.19-staging.2 → 0.16.19-staging.4
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/dist/control-plane-tasks.js +168 -25
- package/dist/server.js +5 -1
- package/dist/work-result-outbox.js +55 -0
- package/dist/worktree.js +30 -33
- package/package.json +1 -1
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* issues (and Slack) across many repos/nodes. Pure HTTP + claim/loop logic lives
|
|
17
17
|
* here; the actual run is injected so the daemon keeps the agent wiring.
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { WorkResultOutbox } from "./work-result-outbox.js";
|
|
19
21
|
import { RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
|
|
20
22
|
/** True when every required tag is among this node's declared capabilities.
|
|
21
23
|
* A node that fails this must never attempt to claim the item — the hard
|
|
@@ -73,12 +75,26 @@ export function resolveControlPlaneTaskConfig(relay, env = process.env, nodeName
|
|
|
73
75
|
capabilities,
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
|
-
async function cp(cfg, method, path) {
|
|
78
|
+
async function cp(cfg, method, path, claimToken, body, signal) {
|
|
79
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
77
80
|
return fetch(`${cfg.controlPlaneUrl}${path}`, {
|
|
78
81
|
method,
|
|
79
|
-
|
|
82
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
83
|
+
headers: { authorization: `Bearer ${cfg.enrollmentToken}`, 'content-type': 'application/json', ...(claimToken ? { 'x-bivy-work-claim': claimToken } : {}) },
|
|
84
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
80
85
|
});
|
|
81
86
|
}
|
|
87
|
+
async function sendWorkResult(cfg, result) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await cp(cfg, 'POST', `/node/work/${encodeURIComponent(result.id)}/${result.action}`, result.claimToken);
|
|
90
|
+
if (res.ok)
|
|
91
|
+
return 'acknowledged';
|
|
92
|
+
if (res.status === 409 || res.status === 404)
|
|
93
|
+
return 'lost';
|
|
94
|
+
}
|
|
95
|
+
catch { /* Outbox retains the intent; retry delivery without agent work. */ }
|
|
96
|
+
return 'retry';
|
|
97
|
+
}
|
|
82
98
|
async function transitionWork(cfg, id, action) {
|
|
83
99
|
// Best-effort — a dropped transition never loses the run itself — but NOT
|
|
84
100
|
// silent: a swallowed `complete`/`fail`/`needs-attention` leaves the control
|
|
@@ -101,10 +117,13 @@ export async function fetchPendingWork(cfg) {
|
|
|
101
117
|
const data = (await res.json().catch(() => ({})));
|
|
102
118
|
return Array.isArray(data.items) ? data.items : [];
|
|
103
119
|
}
|
|
104
|
-
/**
|
|
120
|
+
/** Consume the authoritative claim snapshot, not the earlier queue listing. */
|
|
105
121
|
export async function claimWork(cfg, id) {
|
|
106
|
-
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim
|
|
107
|
-
|
|
122
|
+
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`, randomUUID());
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
return undefined;
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
return data.item ?? { id }; // compatibility with older control planes
|
|
108
127
|
}
|
|
109
128
|
/** Renew ownership and retain the reason a renewal was rejected. Cancellation is
|
|
110
129
|
* intentionally distinct from a generic lost lease so an active agent can be
|
|
@@ -131,11 +150,12 @@ export async function needsAttentionWork(cfg, id) {
|
|
|
131
150
|
* Best-effort: a dropped report loses one evidence update, never the run
|
|
132
151
|
* itself. It is not throwing, but the failure is logged (A4) so a persistently
|
|
133
152
|
* failing evidence channel is visible in diagnostics instead of silent. */
|
|
134
|
-
export async function reportEvidence(cfg, id, patch) {
|
|
153
|
+
export async function reportEvidence(cfg, id, patch, claimToken) {
|
|
135
154
|
try {
|
|
136
155
|
const res = await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
|
|
137
156
|
method: "POST",
|
|
138
|
-
headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
|
|
157
|
+
headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json", ...(claimToken ? { 'x-bivy-work-claim': claimToken } : {}) },
|
|
158
|
+
signal: AbortSignal.timeout(10_000),
|
|
139
159
|
body: JSON.stringify(patch),
|
|
140
160
|
});
|
|
141
161
|
if (!res.ok) {
|
|
@@ -158,6 +178,8 @@ export class ControlPlaneTaskPoller {
|
|
|
158
178
|
policy;
|
|
159
179
|
sleep;
|
|
160
180
|
leaseHeartbeatMs;
|
|
181
|
+
outbox;
|
|
182
|
+
flushingResults;
|
|
161
183
|
constructor(cfg, runItem,
|
|
162
184
|
/** Node's cap on concurrently-running queue sessions (0/undefined = unlimited).
|
|
163
185
|
* Read fresh each tick so the Settings → Nodes value takes effect live. */
|
|
@@ -168,8 +190,11 @@ export class ControlPlaneTaskPoller {
|
|
|
168
190
|
this.policy = options.policy;
|
|
169
191
|
this.sleep = options.sleep ?? defaultSleep;
|
|
170
192
|
this.leaseHeartbeatMs = options.leaseHeartbeatMs ?? 30_000;
|
|
193
|
+
this.outbox = new WorkResultOutbox(options.resultDirectory, `${cfg.controlPlaneUrl}:${cfg.enrollmentToken}`);
|
|
171
194
|
}
|
|
172
195
|
start() {
|
|
196
|
+
if (this.timer)
|
|
197
|
+
return;
|
|
173
198
|
void this.tick();
|
|
174
199
|
this.timer = setInterval(() => void this.tick(), this.cfg.pollMs);
|
|
175
200
|
this.timer.unref?.();
|
|
@@ -187,10 +212,10 @@ export class ControlPlaneTaskPoller {
|
|
|
187
212
|
}
|
|
188
213
|
void this.tick();
|
|
189
214
|
}
|
|
190
|
-
/**
|
|
191
|
-
*
|
|
215
|
+
/** Include undelivered results so an ephemeral machine cannot tear down its
|
|
216
|
+
* only durable outbox while completion reconciliation is still pending. */
|
|
192
217
|
inFlightCount() {
|
|
193
|
-
return this.inFlight.size;
|
|
218
|
+
return new Set([...this.inFlight.keys(), ...this.outbox.list().map(result => result.id)]).size;
|
|
194
219
|
}
|
|
195
220
|
/**
|
|
196
221
|
* Replace the routing labels this live poller serves.
|
|
@@ -212,8 +237,10 @@ export class ControlPlaneTaskPoller {
|
|
|
212
237
|
stop() {
|
|
213
238
|
if (this.timer)
|
|
214
239
|
clearInterval(this.timer);
|
|
240
|
+
this.timer = undefined;
|
|
215
241
|
}
|
|
216
242
|
async tick() {
|
|
243
|
+
await this.flushResults();
|
|
217
244
|
let items;
|
|
218
245
|
try {
|
|
219
246
|
items = await fetchPendingWork(this.cfg);
|
|
@@ -224,7 +251,7 @@ export class ControlPlaneTaskPoller {
|
|
|
224
251
|
const max = this.maxConcurrent?.() ?? 0;
|
|
225
252
|
const running = [];
|
|
226
253
|
for (const item of items) {
|
|
227
|
-
if (this.inFlight.has(item.id))
|
|
254
|
+
if (this.inFlight.has(item.id) || this.outbox.has(item.id))
|
|
228
255
|
continue;
|
|
229
256
|
// Hard block: never contend for an item requiring a capability this
|
|
230
257
|
// node hasn't declared. It stays pending for a node that has it (or
|
|
@@ -268,12 +295,18 @@ export class ControlPlaneTaskPoller {
|
|
|
268
295
|
// Claim first so only one node runs it; skip if another node won (no
|
|
269
296
|
// claim → not ours → don't run or complete it). A heartbeat keeps the
|
|
270
297
|
// finite lease alive; process death stops it and makes the item reclaimable.
|
|
271
|
-
|
|
298
|
+
const claimed = await claimWork(this.cfg, item.id);
|
|
299
|
+
if (!claimed || run.state !== 'active')
|
|
272
300
|
return;
|
|
301
|
+
item = { ...item, ...claimed };
|
|
302
|
+
run.claimToken = claimed.claimToken;
|
|
303
|
+
this.setLeaseDeadline(run, claimed.leaseExpiresAt);
|
|
273
304
|
run.heartbeat = setInterval(() => void this.checkLease(item.id, run), this.leaseHeartbeatMs);
|
|
274
305
|
run.heartbeat.unref?.();
|
|
275
|
-
const report = (patch) => reportEvidence(this.cfg, item.id, patch);
|
|
276
|
-
await
|
|
306
|
+
const report = (patch) => reportEvidence(this.cfg, item.id, patch, run.claimToken);
|
|
307
|
+
const started = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(item.id)}/running`, run.claimToken);
|
|
308
|
+
if (!started.ok)
|
|
309
|
+
return; // Never execute after a rejected lifecycle transition.
|
|
277
310
|
if (run.state !== "active")
|
|
278
311
|
return;
|
|
279
312
|
console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
|
|
@@ -286,14 +319,105 @@ export class ControlPlaneTaskPoller {
|
|
|
286
319
|
return;
|
|
287
320
|
await this.runWithPolicy(item, report, run);
|
|
288
321
|
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
console.warn(`[control-plane-tasks] work ${item.id} orchestration failed:`, error);
|
|
324
|
+
}
|
|
289
325
|
finally {
|
|
290
326
|
if (run.heartbeat)
|
|
291
327
|
clearInterval(run.heartbeat);
|
|
328
|
+
if (run.deadline)
|
|
329
|
+
clearTimeout(run.deadline);
|
|
292
330
|
// A stale completion must not remove a newer reservation for the same id.
|
|
293
331
|
if (this.inFlight.get(item.id) === run)
|
|
294
332
|
this.inFlight.delete(item.id);
|
|
295
333
|
}
|
|
296
334
|
}
|
|
335
|
+
setLeaseDeadline(run, expiry) {
|
|
336
|
+
if (!expiry && !run.claimToken)
|
|
337
|
+
return; // legacy servers do not advertise a lease
|
|
338
|
+
const remaining = expiry ? Date.parse(expiry) - Date.now() : 0;
|
|
339
|
+
if (run.deadline)
|
|
340
|
+
clearTimeout(run.deadline);
|
|
341
|
+
const lose = () => {
|
|
342
|
+
if (run.state !== 'active')
|
|
343
|
+
return;
|
|
344
|
+
run.state = 'lost';
|
|
345
|
+
run.controller.abort(new Error('Run lease expired without confirmed renewal'));
|
|
346
|
+
if (run.heartbeat)
|
|
347
|
+
clearInterval(run.heartbeat);
|
|
348
|
+
};
|
|
349
|
+
if (!Number.isFinite(remaining) || remaining <= 0) {
|
|
350
|
+
lose();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
run.deadline = setTimeout(lose, Math.max(1, remaining - Math.min(1000, remaining / 10)));
|
|
354
|
+
run.deadline.unref?.();
|
|
355
|
+
}
|
|
356
|
+
flushResults() {
|
|
357
|
+
if (this.flushingResults)
|
|
358
|
+
return this.flushingResults;
|
|
359
|
+
this.flushingResults = (async () => {
|
|
360
|
+
for (const result of this.outbox.list()) {
|
|
361
|
+
// Active workers own their delivery/heartbeat loop.
|
|
362
|
+
if (this.inFlight.has(result.id))
|
|
363
|
+
continue;
|
|
364
|
+
const status = await sendWorkResult(this.cfg, result);
|
|
365
|
+
if (status !== 'retry')
|
|
366
|
+
this.outbox.remove(result.id);
|
|
367
|
+
}
|
|
368
|
+
})().catch(error => console.warn('[control-plane-tasks] outcome reconciliation failed:', error))
|
|
369
|
+
.finally(() => { this.flushingResults = undefined; });
|
|
370
|
+
return this.flushingResults;
|
|
371
|
+
}
|
|
372
|
+
async finishWork(id, action, run) {
|
|
373
|
+
const result = { id, action, claimToken: run.claimToken };
|
|
374
|
+
this.outbox.put(result);
|
|
375
|
+
// Keep ownership while retrying delivery, but never beyond its confirmed
|
|
376
|
+
// deadline. After a restart the outbox is reconciled before queue intake.
|
|
377
|
+
do {
|
|
378
|
+
const status = await sendWorkResult(this.cfg, result);
|
|
379
|
+
if (status !== 'retry') {
|
|
380
|
+
this.outbox.remove(id);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!run.claimToken)
|
|
384
|
+
return; // legacy server: retain for the recovery poll
|
|
385
|
+
await this.sleep(1000);
|
|
386
|
+
} while (run.state === 'active');
|
|
387
|
+
}
|
|
388
|
+
/** Redeliver the same reservation after uncertain acknowledgement, without
|
|
389
|
+
* consuming another attempt or allowing agent work beyond our lease. */
|
|
390
|
+
async reserveAttempt(id, attempt, run) {
|
|
391
|
+
while (run.state === 'active') {
|
|
392
|
+
let rejected = false;
|
|
393
|
+
try {
|
|
394
|
+
const response = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(id)}/attempt`, run.claimToken, { attempt }, run.controller.signal);
|
|
395
|
+
if (response.ok) {
|
|
396
|
+
const data = await response.json();
|
|
397
|
+
if (data.item?.attempt !== attempt + 1)
|
|
398
|
+
throw new Error('Invalid attempt reservation acknowledgement');
|
|
399
|
+
return run.state === 'active' ? data.item.attempt : undefined;
|
|
400
|
+
}
|
|
401
|
+
rejected = response.status !== 408 && response.status !== 429 && response.status < 500;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (run.state !== 'active')
|
|
405
|
+
return undefined;
|
|
406
|
+
console.warn(`[control-plane-tasks] work ${id} attempt reservation failed:`, error instanceof Error ? error.message : error);
|
|
407
|
+
}
|
|
408
|
+
if (rejected) {
|
|
409
|
+
// A conflict may mean either exhausted budget or lost ownership.
|
|
410
|
+
// Confirm ownership before parking; cancelled/stale workers just stop.
|
|
411
|
+
await this.checkLease(id, run);
|
|
412
|
+
if (run.state === 'active')
|
|
413
|
+
await this.finishWork(id, 'needs-attention', run);
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
if (run.state === 'active')
|
|
417
|
+
await this.sleep(1000);
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
297
421
|
checkLease(id, run) {
|
|
298
422
|
if (run.state !== "active")
|
|
299
423
|
return Promise.resolve();
|
|
@@ -301,9 +425,17 @@ export class ControlPlaneTaskPoller {
|
|
|
301
425
|
return run.leaseCheck;
|
|
302
426
|
run.leaseCheck = (async () => {
|
|
303
427
|
try {
|
|
304
|
-
const
|
|
305
|
-
|
|
428
|
+
const response = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(id)}/heartbeat`, run.claimToken);
|
|
429
|
+
const data = await response.json();
|
|
430
|
+
if (this.inFlight.get(id) !== run || run.state !== 'active')
|
|
431
|
+
return;
|
|
432
|
+
if (response.ok) {
|
|
433
|
+
this.setLeaseDeadline(run, data.leaseExpiresAt);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (response.status !== 409 && response.status !== 404)
|
|
306
437
|
return;
|
|
438
|
+
const renewal = data.reason === 'cancelled' ? 'cancelled' : 'lost';
|
|
307
439
|
run.state = renewal;
|
|
308
440
|
if (run.heartbeat) {
|
|
309
441
|
clearInterval(run.heartbeat);
|
|
@@ -312,8 +444,7 @@ export class ControlPlaneTaskPoller {
|
|
|
312
444
|
run.controller.abort(new Error(renewal === "cancelled" ? "Run cancelled" : "Run lease lost"));
|
|
313
445
|
}
|
|
314
446
|
catch (error) {
|
|
315
|
-
//
|
|
316
|
-
// Run alive and let the next heartbeat retry.
|
|
447
|
+
// Transient failures may retry only until the last confirmed deadline.
|
|
317
448
|
console.warn(`[control-plane-tasks] work ${id} heartbeat failed:`, error instanceof Error ? error.message : error);
|
|
318
449
|
}
|
|
319
450
|
finally {
|
|
@@ -333,7 +464,7 @@ export class ControlPlaneTaskPoller {
|
|
|
333
464
|
*/
|
|
334
465
|
async runWithPolicy(item, report, run) {
|
|
335
466
|
let current = item;
|
|
336
|
-
let attempt = 1;
|
|
467
|
+
let attempt = Math.max(1, item.attempt ?? 1);
|
|
337
468
|
let rerouteCount = 0;
|
|
338
469
|
for (;;) {
|
|
339
470
|
if (run.state !== "active")
|
|
@@ -344,12 +475,16 @@ export class ControlPlaneTaskPoller {
|
|
|
344
475
|
await this.runItem({ ...current, attempt }, report, run.controller.signal);
|
|
345
476
|
if (run.state !== "active")
|
|
346
477
|
return;
|
|
347
|
-
await
|
|
478
|
+
await this.finishWork(item.id, 'complete', run);
|
|
348
479
|
return;
|
|
349
480
|
}
|
|
350
481
|
catch (error) {
|
|
351
482
|
// Abort errors are ordinary throws to the policy layer unless guarded.
|
|
352
483
|
// A cancelled/lost Run has no node-side terminal transition or retry.
|
|
484
|
+
if (this.outbox.has(item.id)) {
|
|
485
|
+
console.error(`[control-plane-tasks] work ${item.id} result delivery/persistence failed; agent will not be retried:`, error);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
353
488
|
if (run.state !== "active")
|
|
354
489
|
return;
|
|
355
490
|
// Deployment admission is not an agent failure. Never reroute/retry it
|
|
@@ -362,7 +497,7 @@ export class ControlPlaneTaskPoller {
|
|
|
362
497
|
});
|
|
363
498
|
if (run.state !== "active")
|
|
364
499
|
return;
|
|
365
|
-
await
|
|
500
|
+
await this.finishWork(item.id, 'needs-attention', run);
|
|
366
501
|
return;
|
|
367
502
|
}
|
|
368
503
|
const policy = typeof this.policy === "function" ? this.policy(current) : this.policy;
|
|
@@ -381,11 +516,19 @@ export class ControlPlaneTaskPoller {
|
|
|
381
516
|
await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary, attempt }] });
|
|
382
517
|
if (run.state !== "active")
|
|
383
518
|
return;
|
|
384
|
-
await
|
|
519
|
+
await this.finishWork(item.id, 'needs-attention', run);
|
|
385
520
|
return;
|
|
386
521
|
}
|
|
387
522
|
if (decision.action === "retry" || decision.action === "reroute") {
|
|
388
|
-
|
|
523
|
+
if (run.claimToken) {
|
|
524
|
+
const reserved = await this.reserveAttempt(item.id, attempt, run);
|
|
525
|
+
if (reserved === undefined)
|
|
526
|
+
return;
|
|
527
|
+
attempt = reserved;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
attempt += 1;
|
|
531
|
+
} // legacy control plane
|
|
389
532
|
const kind = decision.action === "retry" ? "retry" : "fallback";
|
|
390
533
|
console.warn(`[control-plane-tasks] item ${item.id} ${kind} (${decision.condition}): ${decision.summary}`);
|
|
391
534
|
await report({
|
|
@@ -425,13 +568,13 @@ export class ControlPlaneTaskPoller {
|
|
|
425
568
|
await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary: decision.summary }] });
|
|
426
569
|
if (run.state !== "active")
|
|
427
570
|
return;
|
|
428
|
-
await
|
|
571
|
+
await this.finishWork(item.id, 'needs-attention', run);
|
|
429
572
|
return;
|
|
430
573
|
}
|
|
431
574
|
console.warn(`[control-plane-tasks] item ${item.id} failed:`, error);
|
|
432
575
|
if (run.state !== "active")
|
|
433
576
|
return;
|
|
434
|
-
await
|
|
577
|
+
await this.finishWork(item.id, 'fail', run);
|
|
435
578
|
return;
|
|
436
579
|
}
|
|
437
580
|
}
|
package/dist/server.js
CHANGED
|
@@ -5097,6 +5097,9 @@ async function executeWorkItem(item, report, signal) {
|
|
|
5097
5097
|
const sandbox = safety.sandbox;
|
|
5098
5098
|
const sessionOpts = {
|
|
5099
5099
|
makeActive: false,
|
|
5100
|
+
// The same durable Run adopts its branch after a retry/reclaim instead of
|
|
5101
|
+
// producing a second randomly named branch/PR on a fresh process.
|
|
5102
|
+
workBranch: `bivy/run-${createHash('sha256').update(item.id).digest('hex').slice(0, 24)}`,
|
|
5100
5103
|
title: item.title,
|
|
5101
5104
|
credentialLabels,
|
|
5102
5105
|
runtimeId: item.runtimeId,
|
|
@@ -5210,6 +5213,7 @@ function startControlPlaneTasksIfConfigured() {
|
|
|
5210
5213
|
// limit, park quota/auth/context); user-authored rulesets can add fallback
|
|
5211
5214
|
// chains. Queue runs are unattended, so they act automatically within bounds.
|
|
5212
5215
|
controlPlanePoller = new ControlPlaneTaskPoller(cfg, runWorkItem, nodeGithubMaxConcurrent, {
|
|
5216
|
+
resultDirectory: path.join(appDir, 'work-results'),
|
|
5213
5217
|
policy: (item) => {
|
|
5214
5218
|
// Repository policy is version-controlled with the code and wins over the
|
|
5215
5219
|
// node-global UI ruleset for this run. The shared clone exists by the time
|
|
@@ -8403,7 +8407,7 @@ async function createGitWorkspaceSession(repoDir, parsed, opts = {}) {
|
|
|
8403
8407
|
// Start from an opaque, git-safe unique branch. The first user message then
|
|
8404
8408
|
// triggers sessionNamer.maybeNameSession(), which renames both the session and local branch
|
|
8405
8409
|
// before the first publish/PR attempt.
|
|
8406
|
-
const branch = `bivy/session-${randomBytes(6).toString("hex")}`;
|
|
8410
|
+
const branch = opts.workBranch ?? `bivy/session-${randomBytes(6).toString("hex")}`;
|
|
8407
8411
|
const record = await createSession(repoDir, undefined, {
|
|
8408
8412
|
worktree: { branch, base },
|
|
8409
8413
|
source: `repo:${parsed.slug}`,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/** Metadata only. Persist before delivery so restarting the daemon retries the
|
|
6
|
+
* acknowledgement, not the agent. Scope files to the enrolled control plane. */
|
|
7
|
+
export class WorkResultOutbox {
|
|
8
|
+
pending = new Map();
|
|
9
|
+
file;
|
|
10
|
+
constructor(directory, scope = "") {
|
|
11
|
+
if (!directory)
|
|
12
|
+
return;
|
|
13
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
this.file = path.join(directory, `${createHash("sha256").update(scope).digest("hex")}.json`);
|
|
15
|
+
if (existsSync(this.file)) {
|
|
16
|
+
const rows = JSON.parse(readFileSync(this.file, "utf8"));
|
|
17
|
+
if (!Array.isArray(rows) || rows.some(row => !row || typeof row.id !== 'string' || !['complete', 'fail', 'needs-attention'].includes(row.action)))
|
|
18
|
+
throw new Error('Invalid work result outbox; refusing to replay work');
|
|
19
|
+
for (const row of rows)
|
|
20
|
+
this.pending.set(row.id, row);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
list() { return [...this.pending.values()]; }
|
|
24
|
+
has(id) { return this.pending.has(id); }
|
|
25
|
+
put(result) { this.pending.set(result.id, result); this.flush(); }
|
|
26
|
+
remove(id) { this.pending.delete(id); this.flush(); }
|
|
27
|
+
flush() {
|
|
28
|
+
if (!this.file)
|
|
29
|
+
return;
|
|
30
|
+
const temp = `${this.file}.tmp`;
|
|
31
|
+
const fd = openSync(temp, 'w', 0o600);
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(fd, JSON.stringify(this.list()));
|
|
34
|
+
fsyncSync(fd);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
renameSync(temp, this.file);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (existsSync(temp))
|
|
44
|
+
unlinkSync(temp);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const dir = openSync(path.dirname(this.file), 'r');
|
|
48
|
+
try {
|
|
49
|
+
fsyncSync(dir);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
closeSync(dir);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/worktree.js
CHANGED
|
@@ -52,7 +52,7 @@ function excludeMeshDir(repoRoot) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
* Create a worktree for `repoDir
|
|
55
|
+
* Create or recover a worktree for `repoDir`, preserving existing work. It lives under
|
|
56
56
|
* `<repoRoot>/.bivy/worktrees/<slug>` by default (excluded from git).
|
|
57
57
|
*/
|
|
58
58
|
export async function createWorktree(opts) {
|
|
@@ -63,41 +63,38 @@ export async function createWorktree(opts) {
|
|
|
63
63
|
const branch = opts.branch ?? `bivy/${slug}`;
|
|
64
64
|
const root = opts.root ?? path.join(repoRoot, ".bivy", "worktrees");
|
|
65
65
|
const wtPath = path.join(root, slug);
|
|
66
|
-
|
|
66
|
+
let base = opts.base ?? (await currentRef(repoRoot));
|
|
67
67
|
excludeMeshDir(repoRoot);
|
|
68
68
|
fs.mkdirSync(root, { recursive: true });
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Reuse the registered checkout in place. Recovery must not force-remove
|
|
70
|
+
// staged, unstaged, untracked or ignored work left by the previous attempt.
|
|
71
|
+
// Use Git's registry rather than merely accepting an existing directory.
|
|
72
|
+
const { stdout: registered } = await exec("git", ["-C", repoRoot, "worktree", "list", "--porcelain", "-z"]);
|
|
73
|
+
const existing = registered.split("\0\0").map(entry => entry.split("\0"))
|
|
74
|
+
.find(fields => fields.includes(`worktree ${wtPath}`));
|
|
75
|
+
if (existing && fs.existsSync(wtPath)) {
|
|
76
|
+
if (!existing.includes(`branch refs/heads/${branch}`))
|
|
77
|
+
throw new Error(`Worktree ${wtPath} is not on expected branch ${branch}`);
|
|
78
|
+
return { path: wtPath, branch, repoRoot };
|
|
71
79
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
else if (base !== "HEAD" && !(await refExists(repoRoot, base))) {
|
|
92
|
-
// Defense in depth for stale internal metadata: callers should resolve a
|
|
93
|
-
// fork base first, but a missing ref must not prevent session stand-up.
|
|
94
|
-
await removeWorktree(repoRoot, wtPath);
|
|
95
|
-
fs.rmSync(wtPath, { recursive: true, force: true });
|
|
96
|
-
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, "HEAD"]);
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
80
|
+
// A manually reaped directory can leave a stale registration. Remove only
|
|
81
|
+
// that registration, without --force (locked or newly dirty work stays safe).
|
|
82
|
+
if (existing)
|
|
83
|
+
await exec("git", ["-C", repoRoot, "worktree", "remove", wtPath]);
|
|
84
|
+
// A remote-only branch does NOT make `worktree add -b` fail. Resolve it
|
|
85
|
+
// before creation, otherwise a fresh Machine silently starts at default HEAD.
|
|
86
|
+
const localExists = await refExists(repoRoot, `refs/heads/${branch}`);
|
|
87
|
+
const remoteExists = !localExists && await refExists(repoRoot, `refs/remotes/origin/${branch}`);
|
|
88
|
+
if (localExists) {
|
|
89
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", wtPath, branch]);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (remoteExists)
|
|
93
|
+
base = `refs/remotes/origin/${branch}`;
|
|
94
|
+
else if (base !== "HEAD" && !(await refExists(repoRoot, base)))
|
|
95
|
+
base = "HEAD";
|
|
96
|
+
// Fail closed on path collisions; never delete an unrelated directory.
|
|
97
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, base]);
|
|
101
98
|
}
|
|
102
99
|
// Opportunistically reuse a sibling worktree's installed deps (node_modules,
|
|
103
100
|
// target, .venv) via copy-on-write when the filesystem supports it, so a
|
package/package.json
CHANGED