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,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
|
+
}
|