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.
- package/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- 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
|
+
}
|