flowviant 0.115.0 → 0.117.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/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/workPreviews.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Session preview claims, tunnels, and retirement. */
|
|
2
|
-
import { FLEET_URL
|
|
2
|
+
import { FLEET_URL } from './config.mjs';
|
|
3
3
|
import { isSafePathSegment } from './git.mjs';
|
|
4
4
|
import { originFor as measuredOrigin } from './listeners.mjs';
|
|
5
5
|
import { openTunnel as openLiveTunnel } from './preview.mjs';
|
|
6
6
|
import { note } from './ui.mjs';
|
|
7
7
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
8
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
8
9
|
|
|
9
10
|
/** `originFor` and `openTunnel` are injectable only so the lane can be driven
|
|
10
11
|
* without a dev server or cloudflared (workPreviews.test.mjs); work.mjs passes
|
|
@@ -19,7 +20,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
19
20
|
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
20
21
|
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
21
22
|
// and that is the reason it is deleted. Here the human runs their own server,
|
|
22
|
-
// `
|
|
23
|
+
// `measureListeners` notices it, and this only ever wraps a port that measurement
|
|
23
24
|
// already named for that session.
|
|
24
25
|
//
|
|
25
26
|
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
@@ -35,47 +36,26 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
35
36
|
// Returns the parsed JSON (or null on any failure) rather than discarding
|
|
36
37
|
// it — an OPEN settle's caller needs to see `data.settled === false`, the
|
|
37
38
|
// sharper share-id check on the server's side of a re-share racing the
|
|
38
|
-
// open's own round trip (see the openTunnel call site below).
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const res = await fetch(PREVIEW_DONE_URL, {
|
|
42
|
-
method: 'POST',
|
|
43
|
-
headers: {
|
|
44
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
45
|
-
'User-Agent': USER_AGENT,
|
|
46
|
-
'Content-Type': 'application/json',
|
|
47
|
-
},
|
|
48
|
-
signal: AbortSignal.timeout(30_000),
|
|
49
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
50
|
-
});
|
|
51
|
-
return await res.json().catch(() => null);
|
|
52
|
-
} catch {
|
|
53
|
-
/* the row stops being confirmed and reads as ended — which is true */
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
};
|
|
39
|
+
// open's own round trip (see the openTunnel call site below). On a failure
|
|
40
|
+
// the row stops being confirmed and reads as ended — which is true.
|
|
41
|
+
const postPreview = (body) => settleLease(PREVIEW_DONE_URL, body, { timeoutMs: 30_000 });
|
|
57
42
|
|
|
58
43
|
// `shareId` is the share the daemon was OFFERED, echoed off the job. A
|
|
59
44
|
// re-share inside the claim's round trip rotates the id server-side, so a
|
|
60
45
|
// claim naming the old one matches nothing and this daemon opens no tunnel
|
|
61
46
|
// for a request that no longer exists. Absent-safe: an older server that
|
|
62
47
|
// does not read the field just claims on the place alone, the old rule.
|
|
63
|
-
|
|
48
|
+
// Could not claim → do nothing at all. The other daemon may have.
|
|
49
|
+
const claimPreview = (sessionId, shareId) =>
|
|
50
|
+
claimFleetLease(PREVIEW_CLAIM_URL, { sessionId, ...(shareId ? { shareId } : {}) });
|
|
51
|
+
|
|
52
|
+
/** Stop a tunnel (a live entry or an open's result) without letting its
|
|
53
|
+
* teardown throw into the lane: the stop is best-effort everywhere. */
|
|
54
|
+
const stopQuietly = (live) => {
|
|
64
55
|
try {
|
|
65
|
-
|
|
66
|
-
method: 'POST',
|
|
67
|
-
headers: {
|
|
68
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
69
|
-
'User-Agent': USER_AGENT,
|
|
70
|
-
'Content-Type': 'application/json',
|
|
71
|
-
},
|
|
72
|
-
signal: AbortSignal.timeout(15_000),
|
|
73
|
-
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, ...(shareId ? { shareId } : {}) }),
|
|
74
|
-
});
|
|
75
|
-
const j = await res.json().catch(() => null);
|
|
76
|
-
return j?.data?.claimed === true;
|
|
56
|
+
live?.stop();
|
|
77
57
|
} catch {
|
|
78
|
-
|
|
58
|
+
/* best-effort */
|
|
79
59
|
}
|
|
80
60
|
};
|
|
81
61
|
|
|
@@ -85,13 +65,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
85
65
|
const stopPreview = async (sessionId, reason) => {
|
|
86
66
|
const live = livePreviews.get(sessionId);
|
|
87
67
|
livePreviews.delete(sessionId);
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
live.stop();
|
|
91
|
-
} catch {
|
|
92
|
-
/* best-effort */
|
|
93
|
-
}
|
|
94
|
-
}
|
|
68
|
+
stopQuietly(live);
|
|
95
69
|
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
96
70
|
// broadcast — every daemon on the credential gets it — and the one holding
|
|
97
71
|
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
@@ -115,6 +89,15 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
115
89
|
});
|
|
116
90
|
};
|
|
117
91
|
|
|
92
|
+
/** One teardown per session at a time: a session mid-claim (an open or
|
|
93
|
+
* another stop on its way) is left to that claim, and the next tick asks
|
|
94
|
+
* again. The stop job and a closed tab share this guard. */
|
|
95
|
+
const stopOnce = (sessionId, reason) => {
|
|
96
|
+
if (previewClaiming.has(sessionId)) return;
|
|
97
|
+
previewClaiming.add(sessionId);
|
|
98
|
+
void stopPreview(sessionId, reason).finally(() => previewClaiming.delete(sessionId));
|
|
99
|
+
};
|
|
100
|
+
|
|
118
101
|
const processPreviewJobs = (jobs) => {
|
|
119
102
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
120
103
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -123,9 +106,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
123
106
|
if (!isSafePathSegment(sessionId)) continue;
|
|
124
107
|
|
|
125
108
|
if (job?.action === 'stop') {
|
|
126
|
-
|
|
127
|
-
previewClaiming.add(sessionId);
|
|
128
|
-
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
109
|
+
stopOnce(sessionId, 'stopped');
|
|
129
110
|
continue;
|
|
130
111
|
}
|
|
131
112
|
|
|
@@ -171,6 +152,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
171
152
|
if (previewClaiming.has(sessionId)) continue;
|
|
172
153
|
previewClaiming.add(sessionId);
|
|
173
154
|
|
|
155
|
+
// EVERY report about this job's share names the share (F017,
|
|
156
|
+
// 2026-09-26): an un-scoped `ended` or `error` could end a replacement
|
|
157
|
+
// share opened on the same session in the meantime. One spelling, so a
|
|
158
|
+
// new teardown callback cannot forget it (audit 2026-10-10, F162).
|
|
159
|
+
// Absent when the job carried none (an older server), the old rule.
|
|
160
|
+
const settleShare = (body) => postPreview({ sessionId, ...body, ...(shareId ? { shareId } : {}) });
|
|
161
|
+
|
|
174
162
|
void (async () => {
|
|
175
163
|
try {
|
|
176
164
|
if (!(await claimPreview(sessionId, shareId))) return; // somebody else has it
|
|
@@ -188,21 +176,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
188
176
|
// machine never measured. A wire string, not a field — no floor.
|
|
189
177
|
const refusal = originFor(wt, port).error;
|
|
190
178
|
if (refusal) {
|
|
191
|
-
await
|
|
192
|
-
sessionId,
|
|
193
|
-
error: refusal,
|
|
194
|
-
...(shareId ? { shareId } : {}),
|
|
195
|
-
});
|
|
179
|
+
await settleShare({ error: refusal });
|
|
196
180
|
return;
|
|
197
181
|
}
|
|
198
182
|
// Replace anything this session already had — one tab, one door.
|
|
199
183
|
const prev = livePreviews.get(sessionId);
|
|
200
184
|
if (prev) {
|
|
201
|
-
|
|
202
|
-
prev.stop();
|
|
203
|
-
} catch {
|
|
204
|
-
/* best-effort */
|
|
205
|
-
}
|
|
185
|
+
stopQuietly(prev);
|
|
206
186
|
livePreviews.delete(sessionId);
|
|
207
187
|
}
|
|
208
188
|
const t = await openTunnel({
|
|
@@ -218,12 +198,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
218
198
|
// without this the app would print "live" over a 502.
|
|
219
199
|
onDead: () => {
|
|
220
200
|
livePreviews.delete(sessionId);
|
|
221
|
-
void
|
|
222
|
-
sessionId,
|
|
223
|
-
ended: true,
|
|
224
|
-
endedReason: 'origin_gone',
|
|
225
|
-
...(shareId ? { shareId } : {}),
|
|
226
|
-
});
|
|
201
|
+
void settleShare({ ended: true, endedReason: 'origin_gone' });
|
|
227
202
|
},
|
|
228
203
|
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
229
204
|
// port (5173…) rebound by any other process on the box would keep
|
|
@@ -236,38 +211,27 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
236
211
|
// owner can re-share the port without restarting the daemon.
|
|
237
212
|
onAbuse: () => {
|
|
238
213
|
livePreviews.delete(sessionId);
|
|
239
|
-
void
|
|
240
|
-
sessionId,
|
|
241
|
-
ended: true,
|
|
242
|
-
endedReason: 'abuse',
|
|
243
|
-
...(shareId ? { shareId } : {}),
|
|
244
|
-
});
|
|
214
|
+
void settleShare({ ended: true, endedReason: 'abuse' });
|
|
245
215
|
},
|
|
246
216
|
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
247
217
|
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
248
218
|
onTunnelGone: () => {
|
|
249
219
|
livePreviews.delete(sessionId);
|
|
250
|
-
void
|
|
251
|
-
sessionId,
|
|
252
|
-
error: 'the tunnel dropped — share it again to reopen.',
|
|
253
|
-
...(shareId ? { shareId } : {}),
|
|
254
|
-
});
|
|
220
|
+
void settleShare({ error: 'the tunnel dropped — share it again to reopen.' });
|
|
255
221
|
},
|
|
256
222
|
});
|
|
257
223
|
if (t.error) {
|
|
258
|
-
await
|
|
224
|
+
await settleShare({ error: t.error });
|
|
259
225
|
return;
|
|
260
226
|
}
|
|
261
227
|
livePreviews.set(sessionId, { port, shareId, url: t.url, stop: t.stop });
|
|
262
228
|
// The gate we ACTUALLY installed, so the app never asserts a door
|
|
263
229
|
// nobody observed. An older server ignores the field.
|
|
264
|
-
const settled = await
|
|
265
|
-
sessionId,
|
|
230
|
+
const settled = await settleShare({
|
|
266
231
|
url: t.url,
|
|
267
232
|
user: t.user,
|
|
268
233
|
password: t.password,
|
|
269
234
|
gate: t.gateMode,
|
|
270
|
-
...(shareId ? { shareId } : {}),
|
|
271
235
|
});
|
|
272
236
|
// A re-share (or a Stop) can land inside this open's own round
|
|
273
237
|
// trip and rotate the share id server-side — the settle is then
|
|
@@ -279,11 +243,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
279
243
|
// settle for an older one).
|
|
280
244
|
if (settled?.data?.settled === false) {
|
|
281
245
|
if (livePreviews.get(sessionId)?.stop === t.stop) livePreviews.delete(sessionId);
|
|
282
|
-
|
|
283
|
-
t.stop();
|
|
284
|
-
} catch {
|
|
285
|
-
/* best-effort */
|
|
286
|
-
}
|
|
246
|
+
stopQuietly(t);
|
|
287
247
|
}
|
|
288
248
|
} finally {
|
|
289
249
|
previewClaiming.delete(sessionId);
|
|
@@ -292,26 +252,6 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
292
252
|
}
|
|
293
253
|
};
|
|
294
254
|
|
|
295
|
-
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
296
|
-
* server can tell a live share from one whose machine went away. Silence
|
|
297
|
-
* must never read as "live". */
|
|
298
|
-
/**
|
|
299
|
-
* The daemon's own shape check on an argv the server parsed.
|
|
300
|
-
*
|
|
301
|
-
* Deliberately a SHAPE check and not a re-parse: the server owns the policy
|
|
302
|
-
* (which argv[0] are allowed, the install refusal, the length caps) and the
|
|
303
|
-
* machine owns the refusal to EXECUTE something malformed. It is duplicated
|
|
304
|
-
* rather than imported because this package ships standalone and cannot
|
|
305
|
-
* depend on the monorepo — the mirror is small, and `devCommand.ts` is where
|
|
306
|
-
* the real rules live.
|
|
307
|
-
*/
|
|
308
|
-
const isPlausibleDevArgv = (argv) =>
|
|
309
|
-
Array.isArray(argv) &&
|
|
310
|
-
argv.length > 0 &&
|
|
311
|
-
argv.length <= 8 &&
|
|
312
|
-
argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
|
|
313
|
-
!argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
|
|
314
|
-
|
|
315
255
|
// ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
|
|
316
256
|
//
|
|
317
257
|
// The machine no longer starts application processes. It never learned to
|
|
@@ -321,7 +261,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
321
261
|
// per ecosystem forever.
|
|
322
262
|
//
|
|
323
263
|
// Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
|
|
324
|
-
// IT. `
|
|
264
|
+
// IT. `measureListeners` attributes a listening socket to a place by the cwd of
|
|
325
265
|
// the process holding it, so a server the driver's own agent started in the
|
|
326
266
|
// tab is measured exactly like one this file used to spawn. The web renders
|
|
327
267
|
// that measured list and a person picks which port to share.
|
|
@@ -331,6 +271,9 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
331
271
|
// and its adopt-across-re-exec dance. If supervision is ever wanted back it
|
|
332
272
|
// returns as "supervise this process", never as "run dev".
|
|
333
273
|
|
|
274
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
275
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
276
|
+
* must never read as "live". */
|
|
334
277
|
const livePreviewIds = () => [...livePreviews.keys()];
|
|
335
278
|
|
|
336
279
|
/**
|
|
@@ -349,9 +292,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
349
292
|
const live = new Set(activeIds);
|
|
350
293
|
for (const sessionId of [...livePreviews.keys()]) {
|
|
351
294
|
if (live.has(sessionId)) continue;
|
|
352
|
-
|
|
353
|
-
previewClaiming.add(sessionId);
|
|
354
|
-
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
295
|
+
stopOnce(sessionId, 'tab_closed');
|
|
355
296
|
}
|
|
356
297
|
};
|
|
357
298
|
|
|
@@ -359,13 +300,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
359
300
|
* them would strand a public hostname until the box rebooted — the exact
|
|
360
301
|
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
361
302
|
const shutdownPreviews = () => {
|
|
362
|
-
for (const [, live] of livePreviews)
|
|
363
|
-
try {
|
|
364
|
-
live.stop();
|
|
365
|
-
} catch {
|
|
366
|
-
/* best-effort */
|
|
367
|
-
}
|
|
368
|
-
}
|
|
303
|
+
for (const [, live] of livePreviews) stopQuietly(live);
|
|
369
304
|
livePreviews.clear();
|
|
370
305
|
};
|
|
371
306
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Claims and execution for stopping a measured session process. */
|
|
2
|
-
import { FLEET_URL
|
|
2
|
+
import { FLEET_URL } from './config.mjs';
|
|
3
3
|
import { isSafePathSegment } from './git.mjs';
|
|
4
4
|
import { measureListeners } from './listeners.mjs';
|
|
5
5
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
6
6
|
import { processAlive } from './procRegistry.mjs';
|
|
7
7
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
8
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
8
9
|
|
|
9
10
|
export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGroups }) {
|
|
10
11
|
const KILL_DONE_URL = fleetEndpoint('kill-done', FLEET_URL);
|
|
@@ -47,41 +48,13 @@ export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGro
|
|
|
47
48
|
// is the failure this whole comment is about.
|
|
48
49
|
const killing = new Set(); // job ids in flight on this tick
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
method: 'POST',
|
|
54
|
-
headers: {
|
|
55
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
56
|
-
'User-Agent': USER_AGENT,
|
|
57
|
-
'Content-Type': 'application/json',
|
|
58
|
-
},
|
|
59
|
-
signal: AbortSignal.timeout(30_000),
|
|
60
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
61
|
-
});
|
|
62
|
-
} catch {
|
|
63
|
-
/* unsettled, and the server expires it — the asker is told, never spun */
|
|
64
|
-
}
|
|
65
|
-
};
|
|
51
|
+
// Unsettled on any failure, and the server expires it — the asker is told,
|
|
52
|
+
// never spun.
|
|
53
|
+
const postKill = (body) => settleLease(KILL_DONE_URL, body, { timeoutMs: 30_000 });
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
method: 'POST',
|
|
71
|
-
headers: {
|
|
72
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
73
|
-
'User-Agent': USER_AGENT,
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
},
|
|
76
|
-
signal: AbortSignal.timeout(15_000),
|
|
77
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
78
|
-
});
|
|
79
|
-
const j = await res.json().catch(() => null);
|
|
80
|
-
return j?.data?.claimed === true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false; // the peer may hold it; doing nothing is the safe answer
|
|
83
|
-
}
|
|
84
|
-
};
|
|
55
|
+
// Could not claim → do nothing: the peer may hold it (fleetPost.mjs's
|
|
56
|
+
// `claimFleetLease`, the one double-run guard).
|
|
57
|
+
const claimKill = (id) => claimFleetLease(KILL_CLAIM_URL, { id });
|
|
85
58
|
|
|
86
59
|
/**
|
|
87
60
|
* Is this pid, RIGHT NOW, one of the things we told the browser about for
|
|
@@ -8,25 +8,26 @@
|
|
|
8
8
|
* shared with the agent approve path.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
11
|
+
import { FLEET_URL } from './config.mjs';
|
|
12
|
+
import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch } from './git.mjs';
|
|
14
13
|
import {
|
|
15
14
|
ensureOpenPr,
|
|
16
15
|
findOpenPr,
|
|
17
|
-
ghFirstLine,
|
|
18
16
|
ghReady,
|
|
19
17
|
mergeAndVerifyTip,
|
|
20
18
|
PR_URL_RE,
|
|
21
19
|
prTargetsOtherBase,
|
|
20
|
+
relayedFailure,
|
|
22
21
|
} from './prWorkflow.mjs';
|
|
23
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
23
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
24
24
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
25
25
|
|
|
26
26
|
export function createWorkPullRequests({
|
|
27
27
|
placeOf,
|
|
28
|
+
placeDir,
|
|
29
|
+
branchAt,
|
|
28
30
|
repoRoot,
|
|
29
|
-
baseDir,
|
|
30
31
|
baseRef,
|
|
31
32
|
gitNet,
|
|
32
33
|
landed,
|
|
@@ -43,40 +44,11 @@ export function createWorkPullRequests({
|
|
|
43
44
|
// one branch would open two PRs. NOTHING here closes a card — done is
|
|
44
45
|
// observed by the landed walk when the merge reaches base.
|
|
45
46
|
const prWorking = new Set();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
52
|
-
'User-Agent': USER_AGENT,
|
|
53
|
-
'Content-Type': 'application/json',
|
|
54
|
-
},
|
|
55
|
-
signal: AbortSignal.timeout(15_000),
|
|
56
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
57
|
-
});
|
|
58
|
-
const j = await res.json().catch(() => null);
|
|
59
|
-
return j?.data?.claimed === true;
|
|
60
|
-
} catch {
|
|
61
|
-
return false; // could not claim → do nothing. The other daemon may have.
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
const settlePr = async (body) => {
|
|
65
|
-
try {
|
|
66
|
-
await fetch(PR_DONE_URL, {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: {
|
|
69
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
70
|
-
'User-Agent': USER_AGENT,
|
|
71
|
-
'Content-Type': 'application/json',
|
|
72
|
-
},
|
|
73
|
-
signal: AbortSignal.timeout(20_000),
|
|
74
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
75
|
-
});
|
|
76
|
-
} catch {
|
|
77
|
-
/* the row expires into an honest no_answer; the re-request is the retry */
|
|
78
|
-
}
|
|
79
|
-
};
|
|
47
|
+
// Could not claim → do nothing. The other daemon may have.
|
|
48
|
+
const claimPr = (id) => claimFleetLease(PR_CLAIM_URL, { id });
|
|
49
|
+
// Twenty seconds, this lane's own ceiling; on any failure the row expires
|
|
50
|
+
// into an honest no_answer and the re-request is the retry.
|
|
51
|
+
const settlePr = (body) => settleLease(PR_DONE_URL, body, { timeoutMs: 20_000 });
|
|
80
52
|
const runPrJob = async (job) => {
|
|
81
53
|
const id = String(job.id);
|
|
82
54
|
if (!(await claimPr(id))) return;
|
|
@@ -97,20 +69,17 @@ export function createWorkPullRequests({
|
|
|
97
69
|
return;
|
|
98
70
|
}
|
|
99
71
|
// The branch is whatever the session's own worktree HEAD says — the same
|
|
100
|
-
// resolution ship uses, for the same reason
|
|
101
|
-
// driver left it, not where we put it).
|
|
72
|
+
// resolution ship uses (`branchAt`, workPlaces.mjs), for the same reason
|
|
73
|
+
// (the branch is where the driver left it, not where we put it). A place
|
|
74
|
+
// whose directory is gone falls back to the branch that place was CUT on,
|
|
75
|
+
// `session/<place>` — a person's `u-` place lives on `session/u-<id>`,
|
|
76
|
+
// never on `session/<this tab's id>` (audit 2026-10-10, F168).
|
|
77
|
+
// (The checkout always exists, so its fallback is never read and keeps
|
|
78
|
+
// the tab's own name.)
|
|
102
79
|
const sessionId = String(job.sessionId);
|
|
103
80
|
const place = placeOf(sessionId);
|
|
104
|
-
const wt =
|
|
105
|
-
|
|
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
|
-
}
|
|
81
|
+
const wt = placeDir(sessionId);
|
|
82
|
+
const { branch, detached } = branchAt(wt, sessionBranch(place === REPO_PLACE ? sessionId : place));
|
|
114
83
|
if (detached) {
|
|
115
84
|
await settlePr({
|
|
116
85
|
id,
|
|
@@ -146,7 +115,9 @@ export function createWorkPullRequests({
|
|
|
146
115
|
try {
|
|
147
116
|
gitNetIn(['push', '-u', 'origin', branch], pushCwd, 120_000);
|
|
148
117
|
} catch (e) {
|
|
149
|
-
|
|
118
|
+
// SCRUBBED (relayedFailure): this line lands on every delivered card's
|
|
119
|
+
// record and in the tab chat, and a push names the remote URL.
|
|
120
|
+
await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
|
|
150
121
|
return;
|
|
151
122
|
}
|
|
152
123
|
const pr = ensureOpenPr(branch, baseName, { cwd: repoRoot });
|
|
@@ -167,9 +138,8 @@ export function createWorkPullRequests({
|
|
|
167
138
|
// cards over commits that never reached base.
|
|
168
139
|
// No --delete-branch: the local branch may be a live worktree's HEAD.
|
|
169
140
|
// THE BASE IS RE-ASKED BEFORE THE MERGE, never trusted from the open step:
|
|
170
|
-
// somebody can retarget a PR between Deliver and Approve, and
|
|
171
|
-
//
|
|
172
|
-
// targets now.
|
|
141
|
+
// somebody can retarget a PR between Deliver and Approve, and the merge
|
|
142
|
+
// acts on whichever open PR the branch has, into whatever it targets now.
|
|
173
143
|
const wrongBase = otherBase(prTargetsOtherBase(findOpenPr(branch, { cwd: repoRoot }), baseName));
|
|
174
144
|
if (wrongBase) {
|
|
175
145
|
await settlePr({ id, outcome: 'failed', detail: wrongBase });
|
|
@@ -178,7 +148,7 @@ export function createWorkPullRequests({
|
|
|
178
148
|
try {
|
|
179
149
|
gitNetIn(['push', 'origin', branch], pushCwd, 120_000);
|
|
180
150
|
} catch (e) {
|
|
181
|
-
await settlePr({ id, outcome: 'failed', detail:
|
|
151
|
+
await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
|
|
182
152
|
return;
|
|
183
153
|
}
|
|
184
154
|
// The tip is the LOCAL branch — the state the reviewer approved and the
|
|
@@ -201,6 +171,17 @@ export function createWorkPullRequests({
|
|
|
201
171
|
await settlePr({ id, outcome: 'failed', detail: merged.error });
|
|
202
172
|
return;
|
|
203
173
|
}
|
|
174
|
+
// GitHub lists no open pull request FROM this branch in this repository,
|
|
175
|
+
// so nothing was merged — never a PR picked by a selector that only looks
|
|
176
|
+
// like this branch (prWorkflow.mjs).
|
|
177
|
+
if (merged.noPr) {
|
|
178
|
+
await settlePr({
|
|
179
|
+
id,
|
|
180
|
+
outcome: 'failed',
|
|
181
|
+
detail: `there is no open pull request from ${branch} in this repository to merge — deliver again to open one`,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
204
185
|
if (merged.notOnBase) {
|
|
205
186
|
await settlePr({
|
|
206
187
|
id,
|