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,236 @@
1
+ /** Claims and execution for stopping a measured session process. */
2
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
3
+ import { isSafePathSegment } from './git.mjs';
4
+ import { measureListeners } from './listeners.mjs';
5
+ import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
6
+ import { processAlive } from './procRegistry.mjs';
7
+
8
+ export function createWorkProcesses({ placeDir, reportPlaceWorktrees, state }) {
9
+ const KILL_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-done');
10
+ const KILL_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-claim');
11
+
12
+ const { sessionGroups } = state;
13
+
14
+ // ── stopping one measured process ────────────────────────────────────────
15
+ //
16
+ // The driver points at a row the MACHINE reported and says stop. Flowviant
17
+ // never picks the target, never sweeps, never signals anything on its own
18
+ // initiative, and never signals a GROUP — teardown deliberately SIGTERMs the
19
+ // CLI child and not its group precisely so an unattended auto-update cannot
20
+ // take the driver's dev server with it, and a control that signalled groups
21
+ // would hand that outcome back one click at a time.
22
+ //
23
+ // WHY THIS EXISTS AT ALL, since "ask your Claude to kill it" looks like it
24
+ // already covers it. It does not, and it fails hardest in the case that
25
+ // motivates it: every tab one person owns shares ONE place, the cross-process
26
+ // turn lock is per-place and deliberately un-scoped, so while any other tab
27
+ // of yours is mid-turn a new turn does not spawn — it warns and waits. If the
28
+ // runaway process you want stopped is being held by a turn that is hung, the
29
+ // turn that would kill it never runs. (Two more: FLOWVIANT_SAFE=1 — which the
30
+ // README recommends on a shared box — has no kill, pkill, lsof or ss in its
31
+ // allowlist; and under the README's own top hardening tip, a daemon on its
32
+ // own OS user, the operator's dev server is EPERM to the agent.)
33
+ //
34
+ // A PID IS NOT AN IDENTITY, and this is the whole safety argument. Pids are
35
+ // recycled, the row the browser is looking at is up to a sweep old, and the
36
+ // instance lock already learned this the expensive way — its own comment says
37
+ // "a looser version of this check SIGTERMed one". So the pid the server sends
38
+ // is a REQUEST, never an authority: this re-derives attribution from the
39
+ // kernel immediately before signalling, and refuses unless the pid is STILL
40
+ // in one of this tab's process groups or STILL holding a socket in this
41
+ // tab's place. A recycled pid belonging to something else fails that, which
42
+ // is the property a start-time witness would have bought at the cost of
43
+ // another wire field.
44
+ //
45
+ // CLAIMED, not read, for the reason `processPreviewJobs` states: two daemons
46
+ // legitimately share one credential and both are handed the same array.
47
+ // Signalling twice is survivable; signalling twice with a recycle in between
48
+ // is the failure this whole comment is about.
49
+ const killing = new Set(); // job ids in flight on this tick
50
+
51
+ const postKill = async (body) => {
52
+ try {
53
+ await fetch(KILL_DONE_URL, {
54
+ method: 'POST',
55
+ headers: {
56
+ Authorization: `Bearer ${FLEET_TOKEN}`,
57
+ 'User-Agent': USER_AGENT,
58
+ 'Content-Type': 'application/json',
59
+ },
60
+ signal: AbortSignal.timeout(30_000),
61
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
62
+ });
63
+ } catch {
64
+ /* unsettled, and the server expires it — the asker is told, never spun */
65
+ }
66
+ };
67
+
68
+ const claimKill = async (id) => {
69
+ try {
70
+ const res = await fetch(KILL_CLAIM_URL, {
71
+ method: 'POST',
72
+ headers: {
73
+ Authorization: `Bearer ${FLEET_TOKEN}`,
74
+ 'User-Agent': USER_AGENT,
75
+ 'Content-Type': 'application/json',
76
+ },
77
+ signal: AbortSignal.timeout(15_000),
78
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
79
+ });
80
+ const j = await res.json().catch(() => null);
81
+ return j?.data?.claimed === true;
82
+ } catch {
83
+ return false; // the peer may hold it; doing nothing is the safe answer
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Is this pid, RIGHT NOW, one of the things we told the browser about for
89
+ * this session? Two lanes, matching the two lists a row can come from.
90
+ *
91
+ * Deliberately re-measured rather than read from anything cached: a cache is
92
+ * exactly as old as the report the browser is acting on, and staleness is the
93
+ * hazard.
94
+ */
95
+ const killTargetOk = (sessionId, pid) => {
96
+ const groups = sessionGroups.get(sessionId);
97
+ if (groups && groups.size) {
98
+ const alive = liveGroups(groups);
99
+ const rows = measureProcesses(alive)?.rows ?? [];
100
+ if (rows.some((r) => r.pid === pid)) return true;
101
+ }
102
+ const wt = placeDir(sessionId);
103
+ if (wt) {
104
+ try {
105
+ if (measureListeners(wt).rows.some((r) => r.pid === pid)) return true;
106
+ } catch {
107
+ /* unmeasurable → not verified → refused, which is the safe direction */
108
+ }
109
+ }
110
+ return false;
111
+ };
112
+
113
+ /**
114
+ * How long to watch for the process to actually go before answering.
115
+ *
116
+ * SIGTERM is a REQUEST, not an event: a dev server traps it and tears down
117
+ * its children, which takes a beat. Answering the instant the signal returns
118
+ * would report "signalled" over a process that is about to die, and the
119
+ * surface would then offer Force stop on something already on its way out.
120
+ *
121
+ * Four seconds is long enough for the ordinary teardown and short enough that
122
+ * a person is still looking at the row. Past it the honest answer is that the
123
+ * signal landed and the thing is still there — which is a real state, and the
124
+ * one where escalating actually means something.
125
+ */
126
+ const KILL_GRACE_MS = 4000;
127
+
128
+ const waitForExit = async (pid) => {
129
+ const until = Date.now() + KILL_GRACE_MS;
130
+ while (Date.now() < until) {
131
+ if (!processAlive(pid)) return true;
132
+ await new Promise((r) => setTimeout(r, 200));
133
+ }
134
+ return !processAlive(pid);
135
+ };
136
+
137
+ const runKill = async (job) => {
138
+ const id = String(job.id);
139
+ const sessionId = String(job.sessionId || '');
140
+ const pid = Number(job.pid);
141
+ const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
142
+
143
+ /**
144
+ * CLAIM FIRST, EVEN FOR THE ANSWERS THAT SIGNAL NOTHING.
145
+ *
146
+ * `unsupported` and `not_found` cost nothing to produce, which is exactly
147
+ * why they must be leased: two daemons on one credential are handed the
148
+ * same job, and the one holding NOTHING reaches these branches without
149
+ * measuring anything or making a round trip — so it would answer first,
150
+ * settle the row, and the machine that could actually have signalled would
151
+ * find the job already closed and never touch the process. The person reads
152
+ * "the pid is no longer one of this tab's", which is a real sentence, over a
153
+ * watcher that is still running.
154
+ *
155
+ * The server enforces holder-only settles again (it briefly accepted these
156
+ * two unclaimed, which is the bug above), so an unclaimed post here would
157
+ * simply be dropped. One extra round trip on a path nobody is waiting on.
158
+ */
159
+ if (!(await claimKill(id))) return;
160
+ if (!processesSupported()) {
161
+ await postKill({ id, outcome: 'unsupported' });
162
+ return;
163
+ }
164
+ if (!killTargetOk(sessionId, pid)) {
165
+ // Not a lie and not a failure: the process is genuinely no longer one of
166
+ // this tab's, which is the common case when somebody clicks a row that
167
+ // has since exited. The asker gets that sentence rather than a spinner —
168
+ // and the RE-MEASURE below is what takes the stale row off their screen,
169
+ // since a row you can click for something already gone is the readout
170
+ // being behind, not the person being wrong.
171
+ await postKill({ id, outcome: 'not_found' });
172
+ await remeasureAfterKill(sessionId);
173
+ return;
174
+ }
175
+ try {
176
+ process.kill(pid, signal);
177
+ // WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
178
+ // "it stopped" is a fact about the machine, and the machine is standing
179
+ // right here able to check. Reporting the weaker word would also make the
180
+ // Force stop offer wrong for the whole window, since escalating only
181
+ // means something while the process is genuinely still there.
182
+ const gone = await waitForExit(pid);
183
+ await postKill({ id, outcome: gone ? 'stopped' : 'signalled', signal });
184
+ } catch (e) {
185
+ // EPERM is the daemon-on-its-own-user posture doing exactly what it is
186
+ // for. Report it as its own word: "we may not" and "it was gone" are
187
+ // different sentences and the surface says which.
188
+ await postKill({ id, outcome: e?.code === 'ESRCH' ? 'not_found' : 'error', detail: String(e?.code || e) });
189
+ }
190
+ await remeasureAfterKill(sessionId);
191
+ };
192
+
193
+ /**
194
+ * THE LIST THE PERSON IS LOOKING AT WAS MEASURED BEFORE ANY OF THIS.
195
+ *
196
+ * Without this the row survives the thing it describes: the panel renders the
197
+ * last sweep's `listening`, the sweep is on a SIXTY-SECOND beat, and the
198
+ * reported outcome sits next to a port row still claiming to be live. The
199
+ * first person to use it said exactly that — "i clicked stop on the listening
200
+ * but its still running… then it finally disappears".
201
+ *
202
+ * The rule it was missing is one this file already keeps everywhere else: an
203
+ * action that changes what the machine would measure must cause a new
204
+ * measurement. A turn settling does it; a kill did not. `reportSessionWorktree`
205
+ * is the un-throttled per-session path built for precisely this and it was
206
+ * being called from exactly one place.
207
+ *
208
+ * Never awaited by the caller's answer path: the outcome is posted first, so
209
+ * a slow re-measure can delay the list but never the sentence.
210
+ */
211
+ const remeasureAfterKill = async (sessionId) => {
212
+ try {
213
+ await reportPlaceWorktrees(sessionId);
214
+ } catch {
215
+ /* the 60s sweep still carries it — this only makes it prompt */
216
+ }
217
+ };
218
+
219
+ const processKillJobs = (jobs) => {
220
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
221
+ for (const job of jobs.slice(0, 5)) {
222
+ const id = String(job?.id || '');
223
+ const pid = Number(job?.pid);
224
+ // Bounded at the boundary the same way sessionId and port already are.
225
+ // pid 1 is init and is never something a tab started; a signal there
226
+ // would ask the kernel to shut the box down.
227
+ if (!id || killing.has(id)) continue;
228
+ if (!Number.isInteger(pid) || pid <= 1 || pid > 4_294_967_295) continue;
229
+ if (!isSafePathSegment(String(job?.sessionId || ''))) continue;
230
+ killing.add(id);
231
+ void runKill(job).finally(() => killing.delete(id));
232
+ }
233
+ };
234
+
235
+ return { processKillJobs };
236
+ }
@@ -0,0 +1,313 @@
1
+ /** Session pull-request claims, publishing, and merges. */
2
+ import { existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
6
+ import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
7
+
8
+ export const ghFirstLine = (e) =>
9
+ ((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
10
+ 'failed')
11
+ .slice(0, 400);
12
+ export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
13
+
14
+ export function createWorkPullRequests({
15
+ placeOf,
16
+ REPO_PLACE,
17
+ repoRoot,
18
+ baseDir,
19
+ baseRef,
20
+ gitNet,
21
+ landed,
22
+ onRepoChanged,
23
+ }) {
24
+ const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
25
+ const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
26
+
27
+ // ── PR-mode jobs (projects.mergeMode === 'pr') ──────────────────────────
28
+ // 'open' = push the session's branch and open a PR; 'merge' = merge it.
29
+ // Both under the operator's own `gh` credential from the daemon's inherited
30
+ // env — the same posture the dispatch-era merge path took, and the same one
31
+ // claude.mjs documents for turns. LEASED like a kill: two daemons pushing
32
+ // one branch would open two PRs. NOTHING here closes a card — done is
33
+ // observed by the landed walk when the merge reaches base.
34
+ const prWorking = new Set();
35
+ const claimPr = async (id) => {
36
+ try {
37
+ const res = await fetch(PR_CLAIM_URL, {
38
+ method: 'POST',
39
+ headers: {
40
+ Authorization: `Bearer ${FLEET_TOKEN}`,
41
+ 'User-Agent': USER_AGENT,
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ signal: AbortSignal.timeout(15_000),
45
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
46
+ });
47
+ const j = await res.json().catch(() => null);
48
+ return j?.data?.claimed === true;
49
+ } catch {
50
+ return false; // could not claim → do nothing. The other daemon may have.
51
+ }
52
+ };
53
+ const settlePr = async (body) => {
54
+ try {
55
+ await fetch(PR_DONE_URL, {
56
+ method: 'POST',
57
+ headers: {
58
+ Authorization: `Bearer ${FLEET_TOKEN}`,
59
+ 'User-Agent': USER_AGENT,
60
+ 'Content-Type': 'application/json',
61
+ },
62
+ signal: AbortSignal.timeout(20_000),
63
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
64
+ });
65
+ } catch {
66
+ /* the row expires into an honest no_answer; the re-request is the retry */
67
+ }
68
+ };
69
+ const runPrJob = async (job) => {
70
+ const id = String(job.id);
71
+ if (!(await claimPr(id))) return;
72
+ // gh present and authenticated, or the honest 'unsupported' — its own
73
+ // outcome because "the machine cannot do this at all" and "GitHub said
74
+ // no" read differently to the person who asked.
75
+ try {
76
+ // TIMED OUT, like every other `gh` call. `execFileSync` blocks the whole
77
+ // event loop, so a hung `gh` — an expired token whose refresh hits a
78
+ // black hole, a credential helper waiting on a keyring prompt that has
79
+ // no terminal — stops the roster poll, every in-flight settle, the
80
+ // worktree sweep and the deploy heartbeat (whose 3-minute staleness
81
+ // window then re-queues a deploy this daemon is still running). The
82
+ // AGENT merge path was given exactly these timeouts in 0.77.1; this
83
+ // copy, forty lines of the same logic, was missed.
84
+ execFileSync('gh', ['auth', 'status'], {
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ timeout: 20_000,
87
+ });
88
+ } catch (e) {
89
+ const missing = e?.code === 'ENOENT';
90
+ await settlePr({
91
+ id,
92
+ outcome: 'unsupported',
93
+ detail: missing
94
+ ? 'the GitHub CLI (gh) is not installed on this machine'
95
+ : 'gh is not authenticated on this machine — run `gh auth login` there',
96
+ });
97
+ return;
98
+ }
99
+ // The branch is whatever the session's own worktree HEAD says — the same
100
+ // resolution ship uses, for the same reason (the branch is where the
101
+ // driver left it, not where we put it).
102
+ const sessionId = String(job.sessionId);
103
+ const place = placeOf(sessionId);
104
+ const wt = place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
105
+ let branch = `session/${sessionId}`;
106
+ let detached = false;
107
+ if (existsSync(wt)) {
108
+ try {
109
+ branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
110
+ } catch {
111
+ detached = true;
112
+ }
113
+ }
114
+ if (detached) {
115
+ await settlePr({
116
+ id,
117
+ outcome: 'failed',
118
+ detail: 'the session is on a detached HEAD — no branch to push',
119
+ });
120
+ return;
121
+ }
122
+ // NEVER the base branch. A checkout-place tab (the operator's, at N=1)
123
+ // commonly stands on base, and pushing it would BE the direct push this
124
+ // mode exists to replace — on an unprotected repo the work lands with no
125
+ // PR and the observer closes the cards as landed, PR mode silently
126
+ // defeated by its own open job.
127
+ const baseName = baseBranchName(baseRef());
128
+ if (branch === baseName) {
129
+ await settlePr({
130
+ id,
131
+ outcome: 'failed',
132
+ detail: `the session is on the base branch (${baseName}) — nothing to open a pull request from; work on a branch first`,
133
+ });
134
+ return;
135
+ }
136
+ const pushCwd = existsSync(wt) ? wt : repoRoot;
137
+ /** Adopt only an OPEN PR. gh's branch finder falls back to the most recent
138
+ * MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
139
+ * every later delivery on a long-lived session branch into a silent
140
+ * black hole ('opened'/'merged' over work that never moves). */
141
+ /** …and ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
142
+ * hand into another branch (`staging`, to try it there) was adopted as
143
+ * this delivery's PR, and Approve then merged the unreviewed branch INTO
144
+ * that branch — the ancestry check afterwards blamed "an older PR". The
145
+ * agent merge path has refused this since it shipped; this is the same
146
+ * guard, measured the same way: refused only on a MEASURED mismatch, an
147
+ * absent field adopts as before. Returns `{ url, base }` or null. */
148
+ const openPr = () => {
149
+ try {
150
+ const j = JSON.parse(
151
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
152
+ cwd: repoRoot,
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ timeout: 30_000,
155
+ }).toString()
156
+ );
157
+ if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
158
+ return {
159
+ url: j.url.trim(),
160
+ base: typeof j?.baseRefName === 'string' ? j.baseRefName : null,
161
+ };
162
+ } catch {
163
+ return null; // no PR for the branch at all
164
+ }
165
+ };
166
+ const otherBase = (pr) =>
167
+ pr && pr.base && pr.base !== baseName
168
+ ? `the open pull request for ${branch} targets ${pr.base}, not ${baseName} — retarget or close it, then try again`
169
+ : null;
170
+ if (job.kind !== 'merge') {
171
+ // OPEN: push, then create — or adopt a PR already OPEN for the branch
172
+ // (a re-delivery, or one the driver opened by hand).
173
+ try {
174
+ gitNetIn(['push', '-u', 'origin', branch], pushCwd, 120_000);
175
+ } catch (e) {
176
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
177
+ return;
178
+ }
179
+ const existing = openPr();
180
+ const wrongBase = otherBase(existing);
181
+ if (wrongBase) {
182
+ await settlePr({ id, outcome: 'failed', detail: wrongBase });
183
+ return;
184
+ }
185
+ let url = existing?.url ?? null;
186
+ if (!url) {
187
+ try {
188
+ const out = execFileSync(
189
+ 'gh',
190
+ // `--fill` titles the PR from the branch's own commits — no model
191
+ // call, nothing invented. baseBranchName, not baseRef: gh 422s on
192
+ // a remote-tracking name like origin/main.
193
+ ['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
194
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
195
+ )
196
+ .toString()
197
+ .trim();
198
+ url = out.split('\n').filter(Boolean).pop() ?? null;
199
+ } catch (e) {
200
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
201
+ return;
202
+ }
203
+ }
204
+ await settlePr({
205
+ id,
206
+ outcome: 'opened',
207
+ ...(url && PR_URL_RE.test(url) ? { prUrl: url } : {}),
208
+ });
209
+ return;
210
+ }
211
+ // MERGE: push FIRST — GitHub merges the REMOTE PR tip, and the quiz the
212
+ // reviewer just passed fingerprinted the LOCAL worktree, so merging
213
+ // without a push would land a stale tip while the observer closed the
214
+ // cards over commits that never reached base. Then a MERGE COMMIT, never
215
+ // squash and never rebase — the cards' receipts are commit shas, and a
216
+ // squash rewrites them off base, which would orphan every receipt AND
217
+ // blind the landed walk's trailer read.
218
+ // No --delete-branch: the local branch may be a live worktree's HEAD.
219
+ // THE BASE IS RE-ASKED BEFORE THE MERGE, never trusted from the open step:
220
+ // somebody can retarget a PR between Deliver and Approve, and `gh pr merge
221
+ // <branch>` merges whichever open PR the branch has, into whatever it
222
+ // targets now.
223
+ const wrongBase = otherBase(openPr());
224
+ if (wrongBase) {
225
+ await settlePr({ id, outcome: 'failed', detail: wrongBase });
226
+ return;
227
+ }
228
+ try {
229
+ gitNetIn(['push', 'origin', branch], pushCwd, 120_000);
230
+ } catch (e) {
231
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
232
+ return;
233
+ }
234
+ try {
235
+ execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
236
+ cwd: repoRoot,
237
+ stdio: ['ignore', 'pipe', 'pipe'],
238
+ timeout: 120_000,
239
+ });
240
+ } catch (e) {
241
+ const line = ghFirstLine(e);
242
+ if (!/already merged/i.test(line)) {
243
+ await settlePr({ id, outcome: 'failed', detail: line });
244
+ return;
245
+ }
246
+ }
247
+ // VERIFY before settling 'merged': modern gh exits 0 on an
248
+ // already-MERGED PR, so a dead PR from an earlier delivery reads as
249
+ // success while this branch's newest commits sit unmerged. The branch
250
+ // tip being an ancestor of base is the fact 'merged' claims — check it,
251
+ // with one short retry for the fetch racing GitHub's merge commit.
252
+ const tipSha = (() => {
253
+ try {
254
+ return git(['rev-parse', branch], pushCwd);
255
+ } catch {
256
+ return null;
257
+ }
258
+ })();
259
+ const tipOnBase = () => {
260
+ if (!tipSha) return false;
261
+ try {
262
+ git(['merge-base', '--is-ancestor', tipSha, baseRef()], repoRoot);
263
+ return true;
264
+ } catch {
265
+ return false;
266
+ }
267
+ };
268
+ let merged = false;
269
+ for (let attempt = 0; attempt < 2 && !merged; attempt++) {
270
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
271
+ try {
272
+ gitNet(['fetch', 'origin', '--quiet'], 60_000);
273
+ } catch {
274
+ /* offline — the check below answers from what we have */
275
+ }
276
+ merged = tipOnBase();
277
+ }
278
+ if (!merged) {
279
+ await settlePr({
280
+ id,
281
+ outcome: 'failed',
282
+ detail:
283
+ 'GitHub reports a merge, but this branch\'s newest commits are not on the base branch — the PR that merged was an older one. Deliver again to open a fresh pull request.',
284
+ });
285
+ return;
286
+ }
287
+ await settlePr({ id, outcome: 'merged' });
288
+ // The merge moved base. Observe NOW, so the cards close on this beat
289
+ // rather than the next 3-minute sweep — the same re-measure-after-an-
290
+ // action rule the kill and ship paths keep. (The fetch already ran in
291
+ // the verify loop above.)
292
+ void landed.observe().catch(() => {});
293
+ onRepoChanged();
294
+ };
295
+ const processPrJobs = (jobs) => {
296
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
297
+ for (const job of jobs.slice(0, 5)) {
298
+ const id = String(job?.id || '');
299
+ const sid = String(job?.sessionId || '');
300
+ if (!id || !isSafePathSegment(sid)) continue;
301
+ // Keyed by SESSION, not job id: a deliver-time open and an approve-time
302
+ // merge for one session must run in order (the roster offers them FIFO;
303
+ // running them concurrently would merge before the push-and-create).
304
+ if (prWorking.has(sid)) continue;
305
+ prWorking.add(sid);
306
+ void runPrJob(job)
307
+ .catch(() => {})
308
+ .finally(() => prWorking.delete(sid));
309
+ }
310
+ };
311
+
312
+ return { processPrJobs };
313
+ }