flowviant 0.97.0 → 0.99.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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -0,0 +1,504 @@
1
+ /** Agent merge claims, pull-request approval, and branch cleanup. */
2
+ import { ghFirstLine, PR_URL_RE } from './workPullRequests.mjs';
3
+ import { existsSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { join } from 'node:path';
6
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
7
+ import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
8
+ import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
9
+ import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
10
+ import { warn } from './ui.mjs';
11
+ import { scrub as envScrub } from './env.mjs';
12
+
13
+ export function createWorkAgentMerges({
14
+ repoRoot,
15
+ inPlace,
16
+ baseDir,
17
+ gitNet,
18
+ baseRef,
19
+ runReviewEntry,
20
+ onRepoChanged,
21
+ landed,
22
+ state,
23
+ }) {
24
+ const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
25
+ const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
26
+
27
+ const { agentRemoteAt, agentPublished } = state;
28
+
29
+ // ── THE MERGE ──────────────────────────────────────────────────────────────
30
+ //
31
+ // LEASED, because two `git merge --no-ff` and two pushes over one branch is
32
+ // the loudest duplicate this system can produce. It reuses `mergeOutward`
33
+ // verbatim — the same throwaway-worktree merge, the same once-only retry when
34
+ // two people land at the same moment — because an agent's branch is not
35
+ // special: it is a branch, and this repo already knows how to land one.
36
+ //
37
+ // A SUCCESS CLOSES NOTHING. Done stays OBSERVED: the merge reaches base, the
38
+ // landed observer's own fetch sees it, and the cards close there.
39
+ const agentMerges = new Set(); // agent ids in flight on this tick
40
+
41
+ const claimAgentMerge = async (agentId) => {
42
+ try {
43
+ const res = await fetch(AGENT_MERGE_CLAIM_URL, {
44
+ method: 'POST',
45
+ headers: {
46
+ Authorization: `Bearer ${FLEET_TOKEN}`,
47
+ 'User-Agent': USER_AGENT,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ signal: AbortSignal.timeout(15_000),
51
+ body: JSON.stringify({ agentId, instance: DAEMON_INSTANCE }),
52
+ });
53
+ const j = await res.json().catch(() => null);
54
+ return j?.data?.claimed === true;
55
+ } catch {
56
+ return false; // the peer may hold it; doing nothing is the safe answer
57
+ }
58
+ };
59
+
60
+ const postAgentMerge = async (body) => {
61
+ try {
62
+ await fetch(AGENT_MERGE_DONE_URL, {
63
+ method: 'POST',
64
+ headers: {
65
+ Authorization: `Bearer ${FLEET_TOKEN}`,
66
+ 'User-Agent': USER_AGENT,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ signal: AbortSignal.timeout(30_000),
70
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
71
+ });
72
+ } catch {
73
+ /* the lease lapses and the job is re-offered — a merge is idempotent
74
+ against an already-merged branch, which mergeOutward detects */
75
+ }
76
+ };
77
+
78
+ /**
79
+ * A merge COMMIT needs a git identity and the machine may have none. Prefer
80
+ * the operator's own config; fall back to the daemon's, the same fallback
81
+ * ship's merge keeps, so a bare machine does not fail the fold with
82
+ * "Please tell me who you are".
83
+ */
84
+ const gitMerge = (args, cwd) => {
85
+ let idEnv = null;
86
+ try {
87
+ git(['config', 'user.email'], repoRoot);
88
+ } catch {
89
+ idEnv = {
90
+ GIT_AUTHOR_NAME: 'Flowviant',
91
+ GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
92
+ GIT_COMMITTER_NAME: 'Flowviant',
93
+ GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
94
+ };
95
+ }
96
+ return execFileSync('git', args, {
97
+ cwd,
98
+ encoding: 'utf8',
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
101
+ });
102
+ };
103
+
104
+ const runAgentMerge = async (job) => {
105
+ const agentId = String(job.agentId);
106
+ const place = String(job.placeId || '');
107
+ if (!isSafePathSegment(place)) {
108
+ // Before the claim, and before `report` exists — this one is not covered
109
+ // by the belt below because there is nothing yet to be un-reported.
110
+ await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
111
+ return;
112
+ }
113
+ if (!(await claimAgentMerge(agentId))) return;
114
+
115
+ /**
116
+ * NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
117
+ * ship path carries, and this function did not.
118
+ *
119
+ * Every branch below reports, but an UNEXPECTED throw reports nothing:
120
+ * several `git()` calls in here are bare, and `git()` is `execFileSync`,
121
+ * which throws on any non-zero exit. The agent then sits in `merging`,
122
+ * which refuses Stop and refuses an answer, until an expiry fires — with
123
+ * nothing anywhere saying what went wrong.
124
+ */
125
+ let reported = false;
126
+ /**
127
+ * THE PUBLISHED REF DIES WHEN THE WORK LANDS (0.86.0) — recorded here,
128
+ * retired in the tail below.
129
+ *
130
+ * A `flowviant/*` branch exists so the work survives the box that cut it.
131
+ * Once the commits are on base that job is done, and one ref per agent
132
+ * forever is a branch list nobody wants to read. The server sends the ref
133
+ * only when it heard about a real push, so this never deletes a name we
134
+ * merely composed — and `publishDeleteArgs` refuses anything outside the
135
+ * prefix, because `:refs/heads/<x>` is the most destructive argv in this
136
+ * file.
137
+ *
138
+ * ONLY ON SUCCESS. A failed merge keeps its branch — that is the whole
139
+ * point of the branch — and stopping or declining an agent deletes nothing
140
+ * anywhere: durability is what this feature is, and a ref whose work never
141
+ * landed is the case it exists for. It is recorded in `report` rather than
142
+ * at the three success sites so a fourth one cannot forget it.
143
+ */
144
+ let landedRef = null;
145
+ const report = async (body) => {
146
+ reported = true;
147
+ if (body?.ok === true) landedRef = job.publishedRef ?? null;
148
+ await postAgentMerge(body);
149
+ };
150
+
151
+ // A WRITER on the place, exactly as a ship is. It folds base in and pushes
152
+ // with git in that directory, and no CLI can coordinate with something it
153
+ // does not know exists.
154
+ try {
155
+ await inPlace(place, true, async () => {
156
+ const wt = join(baseDir, 'sessions', place);
157
+ if (!existsSync(wt)) {
158
+ await report({ agentId, ok: false, detail: 'the worktree is gone' });
159
+ return;
160
+ }
161
+ try {
162
+ gitNet(['fetch', 'origin', '--quiet'], 60_000);
163
+ } catch {
164
+ /* offline — the merge fails honestly below */
165
+ }
166
+ // STALE means base moved under this branch while it sat in review. Fold
167
+ // base IN first so the merge that follows is against what is actually
168
+ // there; a conflict here is the same conflict the merge would hit, found
169
+ // one step earlier and in the agent's own directory where it can be
170
+ // resolved.
171
+ if (job.stale) {
172
+ try {
173
+ gitMerge(['merge', '--no-edit', baseRef()], wt);
174
+ } catch (e) {
175
+ await report({
176
+ agentId,
177
+ ok: false,
178
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
179
+ });
180
+ return;
181
+ }
182
+ // The branch changed, so the previous check — and the previous
183
+ // pre-review — answered about a different tree. Re-read it before
184
+ // anything merges: a failed merge sends the agent BACK to review, which
185
+ // is a review-entry beat like any other, and a stale reading standing
186
+ // over a rebased branch is exactly what `precheckSha` exists to void.
187
+ await runReviewEntry(agentId, wt, job.agentName);
188
+ }
189
+ // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
190
+ // a detached HEAD — so the guard below was unreachable and the throw
191
+ // escaped the claimed merge, leaving it unsettled until its lease lapsed.
192
+ let branch = '';
193
+ try {
194
+ branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim();
195
+ } catch {
196
+ branch = '';
197
+ }
198
+ if (!branch) {
199
+ // A detached HEAD names no branch, so there is nothing to merge and
200
+ // nothing to record. An ambiguity in git, not a rule of ours.
201
+ await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
202
+ return;
203
+ }
204
+ const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
205
+ const countOut = git(['rev-list', '--count', `${baseRef()}..HEAD`], wt);
206
+ const count = Number((countOut || '0').trim()) || 0;
207
+ if (count === 0) {
208
+ // Already on base — an idempotent re-offer, or an agent that changed
209
+ // nothing. Reported as a SUCCESS: the branch's work is on base, which
210
+ // is what the caller is asking about.
211
+ await report({ agentId, ok: true, sha: tip || undefined });
212
+ return;
213
+ }
214
+ /**
215
+ * THIS PROJECT MERGES THROUGH A PULL REQUEST.
216
+ *
217
+ * PR mode existed for sessions and was simply not honoured for agents:
218
+ * nothing read the project's merge mode on this path and the direct
219
+ * merge below ran unconditionally. On the repos PR mode exists FOR —
220
+ * branch protection refuses a direct push of a merge commit — every
221
+ * agent approval failed at the push and came back to review carrying
222
+ * git's refusal, forever.
223
+ *
224
+ * PUSH, CREATE, MERGE, in that order, and each step's reasoning is the
225
+ * session PR job's: push first because GitHub merges the REMOTE tip;
226
+ * `--fill` titles from the branch's own commits so nothing is invented;
227
+ * `--merge` and never squash, because the cards' receipts are commit
228
+ * shas and a squash rewrites them off base, orphaning every receipt and
229
+ * blinding the landed observer's trailer read. Adopt only an OPEN PR:
230
+ * gh's branch finder falls back to the most recent merged one, and
231
+ * adopting a dead PR would report success over work that never moves.
232
+ */
233
+ if (job.prMode) {
234
+ try {
235
+ // EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's
236
+ // whole event loop, and this one runs INSIDE the place writer lock —
237
+ // so a `gh` that hangs (an expired token prompting, a network black
238
+ // hole, a hung credential helper) stops every turn on the machine,
239
+ // not merely this merge, and holds the lock while it does.
240
+ execFileSync('gh', ['auth', 'status'], {
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ timeout: 20_000,
243
+ });
244
+ } catch (e) {
245
+ await report({
246
+ agentId,
247
+ ok: false,
248
+ detail:
249
+ e?.code === 'ENOENT'
250
+ ? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
251
+ : `this project merges through pull requests, and gh is not signed in here: ${ghFirstLine(e)}`,
252
+ });
253
+ return;
254
+ }
255
+ const prBase = baseBranchName(baseRef());
256
+ if (branch === prBase) {
257
+ await report({
258
+ agentId,
259
+ ok: false,
260
+ detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
261
+ });
262
+ return;
263
+ }
264
+ /**
265
+ * THE PULL REQUEST'S HEAD IS THE PUBLISHED REF, when there is one
266
+ * (0.86.0).
267
+ *
268
+ * Pushing `session/a-<uuid>` here and reviewing THAT would defeat the
269
+ * whole feature on exactly the projects it is most for: the owner asked
270
+ * for "control over what branch the agents are working on", and a PR
271
+ * mode project is one where people read branches in a host UI. Worse, it
272
+ * leaves TWO refs per agent — the uuid one nothing ever deletes, and the
273
+ * readable one the cleanup below retires — so the survivor is the opaque
274
+ * name this feature exists to replace.
275
+ *
276
+ * ONLY when the local branch really is this agent's own. If somebody
277
+ * checked something else out in the worktree, `session/<place>` is not
278
+ * what is being merged and pushing it under the published name would put
279
+ * work on that ref that nobody approved; the plain push of the checked
280
+ * out branch is the honest fallback.
281
+ */
282
+ const ownBranch = branch === `session/${place}`;
283
+ const head =
284
+ ownBranch && isPublishRef(job.publishedRef) ? job.publishedRef : branch;
285
+ try {
286
+ if (head === branch) {
287
+ gitNetIn(['push', '-u', 'origin', branch], wt, 120_000);
288
+ } else {
289
+ // The same lease discipline the publish lane keeps, and TIMED like
290
+ // every other network call on this path: `git()` has no timeout, and
291
+ // this one runs inside the place writer lock.
292
+ const seen = agentRemoteAt.get(place);
293
+ gitNet(
294
+ publishPushArgs(place, head, seen?.ref === head ? seen.sha : null),
295
+ 120_000
296
+ );
297
+ agentPublished.set(place, { ref: head, sha: tip });
298
+ agentRemoteAt.set(place, { ref: head, sha: tip });
299
+ }
300
+ } catch (e) {
301
+ // SCRUBBED. Every other failure on this path relays `gh`'s own words,
302
+ // but a push writes the REMOTE URL to stderr and a remote can carry a
303
+ // token in its userinfo — so this one line is the only place on the
304
+ // agent merge path that can leak a credential into a stored,
305
+ // team-visible `mergeError`.
306
+ await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
307
+ return;
308
+ }
309
+ let prUrl = null;
310
+ try {
311
+ const j = JSON.parse(
312
+ execFileSync('gh', ['pr', 'view', head, '--json', 'url,state,baseRefName'], {
313
+ cwd: repoRoot,
314
+ stdio: ['ignore', 'pipe', 'pipe'],
315
+ timeout: 30_000,
316
+ }).toString()
317
+ );
318
+ if (j?.state === 'OPEN' && typeof j?.url === 'string') {
319
+ // Adoptable only when it points at the project's own base. A PR
320
+ // somebody opened by hand against another branch would otherwise
321
+ // be merged INTO that branch, and the ok settle would claim work
322
+ // reached base that landed somewhere else entirely. Refused only
323
+ // on a MEASURED mismatch — an absent field adopts as before.
324
+ if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
325
+ await report({
326
+ agentId,
327
+ ok: false,
328
+ detail: `the open pull request for ${head} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
329
+ });
330
+ return;
331
+ }
332
+ prUrl = j.url.trim();
333
+ }
334
+ } catch {
335
+ /* no PR for this branch at all — created below */
336
+ }
337
+ if (!prUrl) {
338
+ try {
339
+ const out = execFileSync(
340
+ 'gh',
341
+ // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
342
+ ['pr', 'create', '--head', head, '--base', prBase, '--fill'],
343
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
344
+ )
345
+ .toString()
346
+ .trim();
347
+ prUrl = out.split('\n').filter(Boolean).pop() ?? null;
348
+ } catch (e) {
349
+ await report({ agentId, ok: false, detail: ghFirstLine(e) });
350
+ return;
351
+ }
352
+ }
353
+ try {
354
+ execFileSync('gh', ['pr', 'merge', head, '--merge'], {
355
+ cwd: repoRoot,
356
+ stdio: ['ignore', 'pipe', 'pipe'],
357
+ timeout: 120_000,
358
+ });
359
+ } catch (e) {
360
+ const line = ghFirstLine(e);
361
+ // Already merged is a SUCCESS: a re-offered job, or somebody merged
362
+ // it in the browser. The observer closes the cards either way.
363
+ if (!/already merged/i.test(line)) {
364
+ await report({
365
+ agentId,
366
+ ok: false,
367
+ detail:
368
+ prUrl && PR_URL_RE.test(prUrl) ? `${line} — the pull request is at ${prUrl}` : line,
369
+ });
370
+ return;
371
+ }
372
+ }
373
+ // VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
374
+ // PR, and on a repo with a merge queue or auto-merge it exits 0 after
375
+ // ENQUEUEING — in both, "merged" is a claim about the future. The tip
376
+ // being an ancestor of base is the fact `ok` asserts, and the server
377
+ // closes every delivered card on this sha the moment it hears it — so
378
+ // measure it, with one short retry for the fetch racing GitHub's
379
+ // merge commit. The same guard the session PR path carries.
380
+ const tipOnBase = () => {
381
+ try {
382
+ git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
383
+ return true;
384
+ } catch {
385
+ return false;
386
+ }
387
+ };
388
+ let landedOnBase = false;
389
+ for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
390
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
391
+ try {
392
+ gitNet(['fetch', 'origin', '--quiet'], 60_000);
393
+ } catch {
394
+ /* offline — the check below answers from what we have */
395
+ }
396
+ landedOnBase = tipOnBase();
397
+ }
398
+ if (!landedOnBase) {
399
+ await report({
400
+ agentId,
401
+ ok: false,
402
+ detail:
403
+ "GitHub accepted the merge, but this branch's tip is not on the base branch — a merge queue may still be running it, or the PR that merged was an older one. Approve again once it lands." +
404
+ (prUrl && PR_URL_RE.test(prUrl) ? ` The pull request is at ${prUrl}.` : ''),
405
+ });
406
+ return;
407
+ }
408
+ // THE TIP, not the merge commit GitHub made: the tip identifies the
409
+ // state we asked to be merged, which is what the receipt is for — the
410
+ // cards themselves close when the landed observer sees the commits
411
+ // arrive on base.
412
+ await report({ agentId, ok: true, sha: tip });
413
+ onRepoChanged();
414
+ landed.observe();
415
+ return;
416
+ }
417
+ try {
418
+ shipMergeOutward({
419
+ tip,
420
+ count,
421
+ branch,
422
+ label: job.agentName || place.slice(0, 12),
423
+ git,
424
+ gitMerge,
425
+ repoRoot,
426
+ tmpDir: join(baseDir, 'ship', place),
427
+ baseRef,
428
+ workingTree: wt,
429
+ warn,
430
+ });
431
+ } catch (e) {
432
+ await report({
433
+ agentId,
434
+ ok: false,
435
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
436
+ });
437
+ return;
438
+ }
439
+ await report({ agentId, ok: true, sha: tip });
440
+ onRepoChanged();
441
+ landed.observe();
442
+ });
443
+ } finally {
444
+ if (!reported) {
445
+ // Belt over braces. A merge this daemon claimed and cannot account for
446
+ // is a FAILURE, said out loud, so the agent goes back to Review with a
447
+ // reason instead of waiting out an expiry that blames nobody.
448
+ await postAgentMerge({
449
+ agentId,
450
+ ok: false,
451
+ detail: 'the merge did not complete — check the daemon log',
452
+ }).catch(() => {});
453
+ }
454
+ /**
455
+ * …AND THE RETIREMENT IS TAIL WORK, OUTSIDE THE PLACE LOCK — the same
456
+ * placement the turn's publish argues for, for the same reason.
457
+ *
458
+ * `gitNet` is `execFileSync`: it blocks the whole event loop for up to its
459
+ * timeout, and inside `inPlace(place, true, …)` it would hold this
460
+ * agent's WRITER lock through a remote's bad day, with the merge already
461
+ * settled and nothing left that the delay serves. This block is PAST the
462
+ * lock: `inPlace` has returned (or thrown) before a `finally` runs.
463
+ *
464
+ * IN THE `finally` rather than after it, so a throw between the ok settle
465
+ * and the end of the locked block cannot strand the ref. `landedRef` is
466
+ * set only by a reported success, so there is nothing here to run on any
467
+ * other path — and a remote that refuses the delete only warns: the merge
468
+ * is the thing that matters, and a landed branch must not become a failed
469
+ * approval.
470
+ *
471
+ * The record goes with the ref. `agentPublished` is what the SWEEP
472
+ * republishes from, so leaving the entry behind would let the next sweep
473
+ * push the ref straight back — an orphan no later merge job can ever
474
+ * carry, and therefore one nothing can delete.
475
+ */
476
+ if (landedRef) {
477
+ agentPublished.delete(place);
478
+ agentRemoteAt.delete(place);
479
+ const args = publishDeleteArgs(landedRef);
480
+ if (args) {
481
+ try {
482
+ gitNet(args, 60_000);
483
+ } catch (e) {
484
+ warn(
485
+ `agent ${agentId}: the published branch ${landedRef} could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`
486
+ );
487
+ }
488
+ }
489
+ }
490
+ }
491
+ };
492
+
493
+ const processAgentMergeJobs = (jobs) => {
494
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
495
+ for (const job of jobs.slice(0, 2)) {
496
+ const id = String(job?.agentId || '');
497
+ if (!id || agentMerges.has(id)) continue;
498
+ agentMerges.add(id);
499
+ void runAgentMerge(job).finally(() => agentMerges.delete(id));
500
+ }
501
+ };
502
+
503
+ return { processAgentMergeJobs, agentMerges };
504
+ }