flowviant 0.54.0 → 0.54.2
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 +26 -0
- package/bin/lib/deploy.mjs +10 -2
- package/bin/lib/fleet.mjs +116 -5
- package/bin/lib/instance.mjs +521 -43
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/preview.mjs +99 -10
- package/bin/lib/work.mjs +37 -1
- package/package.json +2 -2
package/bin/cli.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* `npx flowviant` can reuse a stale cache). A running daemon also self-updates
|
|
20
20
|
* on its own — at startup and when idle — so it stays current without restarts
|
|
21
21
|
* (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
|
|
22
|
+
* `flowviant stop` stops every daemon on this box — the answer to "is one even
|
|
23
|
+
* running?", which otherwise ends in a pid hunt through `ps`.
|
|
22
24
|
*
|
|
23
25
|
* The daemon: install ONCE with a machine credential, then work entirely from
|
|
24
26
|
* Flowviant. It polls GET /api/v2/fleet/agents, and the roster hands it the
|
|
@@ -146,6 +148,30 @@ if (process.argv[2] === 'shot') {
|
|
|
146
148
|
process.exit(0);
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
// `flowviant stop` — stop every flowviant daemon on this machine.
|
|
152
|
+
//
|
|
153
|
+
// THE FRICTION IT REMOVES is not knowing whether one is running. So you run
|
|
154
|
+
// `flowviant`, get a refusal naming a pid in a directory you do not recognise,
|
|
155
|
+
// and go hunting through `ps`. This asks no question and takes no argument: it
|
|
156
|
+
// sweeps every credential's lock file, not just the one this checkout keys to,
|
|
157
|
+
// because a stop command with a scope is one you have to be sure about before
|
|
158
|
+
// you can use it — and being unsure is the whole reason you typed it.
|
|
159
|
+
//
|
|
160
|
+
// It identifies each holder before signalling it and says so when it cannot
|
|
161
|
+
// (see stopAllDaemons); it needs NO credential and NO network — it reads lock
|
|
162
|
+
// files under ~/.flowviant and signals pids — so it runs BEFORE the auth gate,
|
|
163
|
+
// like `shot`. "I don't know what is running" is not a state in which we should
|
|
164
|
+
// also be asking someone to log in.
|
|
165
|
+
//
|
|
166
|
+
// EXIT 0 when it stopped something AND when it found nothing: "no flowviant
|
|
167
|
+
// daemon is running on this machine." is the answer the asker came for, not an
|
|
168
|
+
// error. Non-zero only when something was alive and could not be stopped.
|
|
169
|
+
if (process.argv[2] === 'stop') {
|
|
170
|
+
const { stopAllDaemons } = await import('./lib/instance.mjs');
|
|
171
|
+
const { failed } = stopAllDaemons({ log: (m) => console.log(m) });
|
|
172
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
150
176
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
151
177
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
16
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
17
17
|
import { c, note, ok, warn } from './ui.mjs';
|
|
18
18
|
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
19
|
|
|
@@ -167,7 +167,15 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
167
167
|
void (async () => {
|
|
168
168
|
let beat = null;
|
|
169
169
|
try {
|
|
170
|
-
|
|
170
|
+
// `instance` names THIS PROCESS. The pubkey cannot: it is the env
|
|
171
|
+
// keypair read from one file per home directory, so two daemons on one
|
|
172
|
+
// box share it and a pubkey-only read-back let both "win" the claim
|
|
173
|
+
// and run the same deploy twice concurrently.
|
|
174
|
+
const claimed = await post('deploy-claim', {
|
|
175
|
+
jobId: job.id,
|
|
176
|
+
pubkey: ctx.myPubB64(),
|
|
177
|
+
instance: DAEMON_INSTANCE,
|
|
178
|
+
}).catch(() => null);
|
|
171
179
|
if (!claimed?.claimed) return; // another daemon won the claim
|
|
172
180
|
// Keep the claim fresh while we run — a long deploy must never be
|
|
173
181
|
// re-queued out from under us (that would double-deploy). The async
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -270,6 +270,48 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
|
|
275
|
+
*
|
|
276
|
+
* The daemon is a PULL client — the /fleet/stream socket is a one-way wake
|
|
277
|
+
* nudge with no server→daemon request path — so "stop this machine" can never
|
|
278
|
+
* be a request the server makes of us. It rides the roster RESPONSE instead, on
|
|
279
|
+
* the same `daemon` object the version signal already travels on, which is why
|
|
280
|
+
* it needs no new endpoint and no version floor: an older daemon reads an
|
|
281
|
+
* unknown key as nothing and keeps running, and fail-open is the safe direction
|
|
282
|
+
* for a switch whose failure mode is "your machine went dark".
|
|
283
|
+
*
|
|
284
|
+
* The server decides whether a stop is LIVE — it stamps the credential and only
|
|
285
|
+
* sends the key inside a short honor window — and the daemon does NOT re-derive
|
|
286
|
+
* that. The key's PRESENCE is the command. Evaluating the same TTL on both
|
|
287
|
+
* sides would make clock skew the arbiter of whether a machine may run, and get
|
|
288
|
+
* it wrong in the direction that bricks the box: a relaunch that re-reads an
|
|
289
|
+
* old timestamp and stops itself again, forever.
|
|
290
|
+
*
|
|
291
|
+
* Pure and exported so the decision can be proved without a credential or a
|
|
292
|
+
* live server. `null` means keep running.
|
|
293
|
+
*/
|
|
294
|
+
export function shouldStop(rosterDaemon) {
|
|
295
|
+
const stop = rosterDaemon?.stop;
|
|
296
|
+
// An OBJECT, and not an array: `typeof [] === 'object'`, so the plain typeof
|
|
297
|
+
// guard let `stop: []` — an empty list, which is how this codebase spells "no
|
|
298
|
+
// jobs" on every other roster key — read as a live stop with no reason. A
|
|
299
|
+
// switch that kills a machine gets the narrow test.
|
|
300
|
+
if (!stop || typeof stop !== 'object' || Array.isArray(stop)) return null;
|
|
301
|
+
// Re-sanitized HERE even though the server wrote it: this string is operator
|
|
302
|
+
// prose typed into a SQL UPDATE and then printed straight to a terminal, so
|
|
303
|
+
// control bytes would let a stop reason repaint the console it is being read
|
|
304
|
+
// on, and an unbounded one would bury the line that matters. The WORDING is
|
|
305
|
+
// untouched — the operator's own sentence is the whole point of the field,
|
|
306
|
+
// and paraphrasing it would leave the person at the keyboard guessing.
|
|
307
|
+
const reason = String(stop.reason ?? '')
|
|
308
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
309
|
+
.replace(/\s+/g, ' ')
|
|
310
|
+
.trim()
|
|
311
|
+
.slice(0, 300);
|
|
312
|
+
return { stop: true, reason };
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
274
316
|
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
275
317
|
|
|
@@ -327,7 +369,15 @@ export async function runFleetDaemon() {
|
|
|
327
369
|
// restart, so it takes a word.
|
|
328
370
|
note('run with --takeover to stop it and serve this repo instead.');
|
|
329
371
|
}
|
|
330
|
-
|
|
372
|
+
// WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
|
|
373
|
+
// daemon unguarded beside one we just admitted we cannot see, and in the
|
|
374
|
+
// same repo that is two `git fetch`, two worktree sweeps, and one
|
|
375
|
+
// `retireWorkSessions` deleting directories the other is serving. Offering
|
|
376
|
+
// it as the way out of "I don't know what that process is" would be handing
|
|
377
|
+
// someone the worst option at the moment they have the least information.
|
|
378
|
+
if (!instance.unidentified) {
|
|
379
|
+
note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
|
|
380
|
+
}
|
|
331
381
|
console.log('');
|
|
332
382
|
process.exit(1);
|
|
333
383
|
}
|
|
@@ -732,7 +782,7 @@ export async function runFleetDaemon() {
|
|
|
732
782
|
// Direct enqueue = immediacy; the server's durable regroundJobs list
|
|
733
783
|
// (created by merge-done above, cleared by our reground-done report)
|
|
734
784
|
// is the restart-safe backstop — dedup'd here by groundedIntents.
|
|
735
|
-
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
|
|
785
|
+
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
|
|
736
786
|
} else if (failedReason) {
|
|
737
787
|
// Report into the thread (server narrates + re-arms the merge
|
|
738
788
|
// button + notifies) — the job disappears from the roster.
|
|
@@ -876,7 +926,7 @@ export async function runFleetDaemon() {
|
|
|
876
926
|
wikiQueue.push({ type: 'sweep' });
|
|
877
927
|
void drainWiki();
|
|
878
928
|
};
|
|
879
|
-
const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
|
|
929
|
+
const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
|
|
880
930
|
if (!intentId || groundedIntents.has(intentId)) return;
|
|
881
931
|
groundedIntents.add(intentId);
|
|
882
932
|
wikiQueue.push({
|
|
@@ -889,6 +939,13 @@ export async function runFleetDaemon() {
|
|
|
889
939
|
// frontmatter file list has drifted, or that document a concept rather
|
|
890
940
|
// than a directory.
|
|
891
941
|
dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
|
|
942
|
+
// THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
|
|
943
|
+
// Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
|
|
944
|
+
// shas on every reground job, this function never stored them, and the
|
|
945
|
+
// drain's `task.shas` was undefined on every job — so the re-ground
|
|
946
|
+
// "revived" on 2026-08-22 retried three times against nothing and gave
|
|
947
|
+
// up, on a console nobody reads, on every single ship.
|
|
948
|
+
shas: Array.isArray(shas) ? shas : [],
|
|
892
949
|
});
|
|
893
950
|
void drainWiki();
|
|
894
951
|
};
|
|
@@ -1135,6 +1192,13 @@ export async function runFleetDaemon() {
|
|
|
1135
1192
|
// crash BEFORE this line leaves the job listed for a retry.
|
|
1136
1193
|
regroundAttempts.delete(task.intentId);
|
|
1137
1194
|
await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
|
|
1195
|
+
// The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
|
|
1196
|
+
// second ship writes a fresh durable job, this Set still holds the
|
|
1197
|
+
// taskId, enqueueReground refuses it on every poll forever, and
|
|
1198
|
+
// the never-consumed job churns the wiki-writer lease until a
|
|
1199
|
+
// restart. The job is consumed now, so the guard has done its work;
|
|
1200
|
+
// a FUTURE ship of the same card is new work, not a duplicate.
|
|
1201
|
+
groundedIntents.delete(task.intentId);
|
|
1138
1202
|
}
|
|
1139
1203
|
} catch (e) {
|
|
1140
1204
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
@@ -1219,7 +1283,13 @@ export async function runFleetDaemon() {
|
|
|
1219
1283
|
if (e.auth) {
|
|
1220
1284
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
1221
1285
|
teardown();
|
|
1222
|
-
|
|
1286
|
+
// EXIT 0, for the same reason the commanded-stop path does: a revoked
|
|
1287
|
+
// credential is a terminal, asked-for-by-someone state, and a relaunch
|
|
1288
|
+
// can never fix it. Under `Restart=on-failure` a nonzero code has
|
|
1289
|
+
// systemd relaunch the daemon immediately — a restart loop hammering
|
|
1290
|
+
// dead-credential polls, fighting the Disconnect that revoked it, and
|
|
1291
|
+
// ending in a unit that reads as a crash rather than a kill.
|
|
1292
|
+
process.exit(0);
|
|
1223
1293
|
}
|
|
1224
1294
|
warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
|
|
1225
1295
|
await sleep(RECONCILE_SECONDS);
|
|
@@ -1240,6 +1310,47 @@ export async function runFleetDaemon() {
|
|
|
1240
1310
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
1241
1311
|
if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
|
|
1242
1312
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
1313
|
+
// A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
|
|
1314
|
+
// this sits ABOVE the version signal rather than inside it. Both read the
|
|
1315
|
+
// same `roster.daemon` object, but `handleVersionSignal` can re-exec this
|
|
1316
|
+
// process into a newer build — so checked second, a machine somebody just
|
|
1317
|
+
// told to stop would come back up wearing a different version instead of
|
|
1318
|
+
// going away.
|
|
1319
|
+
const stopSignal = shouldStop(roster.daemon);
|
|
1320
|
+
if (stopSignal) {
|
|
1321
|
+
warn(
|
|
1322
|
+
stopSignal.reason
|
|
1323
|
+
? `stopped by Flowviant — ${stopSignal.reason}`
|
|
1324
|
+
: 'stopped by Flowviant — no reason given.'
|
|
1325
|
+
);
|
|
1326
|
+
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
1327
|
+
// FLUSH the settle queue first, bounded: a queued-but-undelivered report
|
|
1328
|
+
// is a COMPLETED turn whose side effects already happened, and dropping
|
|
1329
|
+
// it re-runs the whole turn on the next start — quota spent twice and
|
|
1330
|
+
// every card write doubled. This path is async (unlike the signal
|
|
1331
|
+
// handlers, which cannot await), so the stop can afford five seconds of
|
|
1332
|
+
// delivery before it obeys.
|
|
1333
|
+
try {
|
|
1334
|
+
await Promise.race([flushWorkReports(), sleep(5)]);
|
|
1335
|
+
} catch {
|
|
1336
|
+
/* undelivered reports re-run; delivering them was best-effort */
|
|
1337
|
+
}
|
|
1338
|
+
// teardown() is NOT optional on this path. Detached preview tunnels
|
|
1339
|
+
// survive this process BY DESIGN, so exiting without it strands a public
|
|
1340
|
+
// hostname pointed into a worktree until somebody reboots the box — which
|
|
1341
|
+
// is precisely the state a remote stop is usually being used to end. It
|
|
1342
|
+
// also kills the session CLIs and the wiki Claude, which would otherwise
|
|
1343
|
+
// keep editing worktrees and burning quota for a machine nobody is
|
|
1344
|
+
// watching any more.
|
|
1345
|
+
teardown();
|
|
1346
|
+
// EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
|
|
1347
|
+
// a failure. Under `Restart=on-failure` a nonzero code has systemd
|
|
1348
|
+
// relaunch the daemon immediately, fighting the very command that stopped
|
|
1349
|
+
// it; exit 0 reads as "the job is done" and leaves it down. The server's
|
|
1350
|
+
// honor window is what makes the other half work — a deliberate relaunch
|
|
1351
|
+
// minutes later comes up clean instead of stopping itself forever.
|
|
1352
|
+
process.exit(0);
|
|
1353
|
+
}
|
|
1243
1354
|
// Keep the daemon current. Safe = no worker mid-task (true at startup, since
|
|
1244
1355
|
// no workers are spawned yet). If it self-updates it re-execs into the new
|
|
1245
1356
|
// version and this process becomes a proxy — stop the loop.
|
|
@@ -1341,7 +1452,7 @@ export async function runFleetDaemon() {
|
|
|
1341
1452
|
for (const j of roster.regroundJobs ?? []) {
|
|
1342
1453
|
const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
|
|
1343
1454
|
if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
|
|
1344
|
-
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
|
|
1455
|
+
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
|
|
1345
1456
|
}
|
|
1346
1457
|
void drainWiki();
|
|
1347
1458
|
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -140,6 +140,9 @@ const record = (repoRoot) =>
|
|
|
140
140
|
// The script we were started from, and what we are. A takeover matches the
|
|
141
141
|
// live command line against `entry` before signalling anything — a lock
|
|
142
142
|
// records a PID, and a crashed daemon's PID can be reused by anything.
|
|
143
|
+
// Locks written before this field existed (0.51.2 through 0.53.0) are
|
|
144
|
+
// matched on the holder's process START TIME instead; stillTheHolder says
|
|
145
|
+
// why that is the weaker of the two claims and still strong enough.
|
|
143
146
|
entry: process.argv[1] || '',
|
|
144
147
|
version: VERSION,
|
|
145
148
|
});
|
|
@@ -175,16 +178,26 @@ function neighbourLockPath(holder, fallback) {
|
|
|
175
178
|
return NEIGHBOUR_PATHS.get(holder) ?? fallback;
|
|
176
179
|
}
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
/** Every daemon lock file on this machine, absolute, sorted for a stable read.
|
|
182
|
+
* ONE scan, because two callers walk this directory for opposite reasons —
|
|
183
|
+
* daemonInSameRepo looking for a neighbour to refuse or replace, stopAllDaemons
|
|
184
|
+
* sweeping the lot — and the FILENAME PATTERN is the thing that must not drift
|
|
185
|
+
* between them: it is what separates our locks from anything else living in
|
|
186
|
+
* ~/.flowviant. An unreadable (or absent) directory is not an error here, it is
|
|
187
|
+
* an empty machine. */
|
|
188
|
+
function lockFiles() {
|
|
181
189
|
try {
|
|
182
|
-
|
|
190
|
+
return readdirSync(join(homedir(), '.flowviant'))
|
|
191
|
+
.filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f))
|
|
192
|
+
.sort()
|
|
193
|
+
.map((f) => join(homedir(), '.flowviant', f));
|
|
183
194
|
} catch {
|
|
184
|
-
return
|
|
195
|
+
return [];
|
|
185
196
|
}
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function daemonInSameRepo(repoRoot, ownPath) {
|
|
200
|
+
for (const path of lockFiles()) {
|
|
188
201
|
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
189
202
|
const holder = readHolder(path);
|
|
190
203
|
if (!holder || !alive(holder.pid)) continue;
|
|
@@ -197,6 +210,175 @@ export function daemonInSameRepo(repoRoot, ownPath) {
|
|
|
197
210
|
return null;
|
|
198
211
|
}
|
|
199
212
|
|
|
213
|
+
/** How far a holder's actual start may sit BEFORE the `startedAt` line it wrote
|
|
214
|
+
* and still be the same process. Measured on this exact path: node boot to the
|
|
215
|
+
* first line of JS is 20ms, and the whole way through importing this module,
|
|
216
|
+
* `git rev-parse --show-toplevel` and base-ref detection to record() is 29-32ms.
|
|
217
|
+
* The worst realistic run is a start that had to take over a NEIGHBOUR's lock
|
|
218
|
+
* first (a 20s grace, then 600ms) and then its own (another 20s), which lands
|
|
219
|
+
* around 41s. 120s is ~3x that worst path and ~4000x the typical one, and it is
|
|
220
|
+
* still short enough that pid reuse cannot reach into it: reuse means cycling
|
|
221
|
+
* the entire pid space (4194304 by default), which no machine does inside two
|
|
222
|
+
* minutes. */
|
|
223
|
+
const TAKEOVER_START_WINDOW_MS = 120_000;
|
|
224
|
+
|
|
225
|
+
/** ...and how far the OTHER way, which is a unit problem rather than a real
|
|
226
|
+
* possibility. `starttime` is quantised to clock ticks and `ps -o etime=` to
|
|
227
|
+
* whole seconds, so a process that genuinely started a moment before its own
|
|
228
|
+
* lock line can compute a hair after it. 5s covers that granularity and
|
|
229
|
+
* nothing else. The window is deliberately asymmetric and BACKWARD-looking: it
|
|
230
|
+
* brackets the daemon's own startup, not "recently", so a freshly forked
|
|
231
|
+
* impostor that inherited a recycled pid lands on this side and is refused. */
|
|
232
|
+
const TAKEOVER_START_SLACK_MS = 5_000;
|
|
233
|
+
|
|
234
|
+
/** USER_HZ — the unit `/proc` publishes `starttime` in. NOT the kernel's internal
|
|
235
|
+
* CONFIG_HZ (250/300/1000): the kernel converts before writing, and USER_HZ is a
|
|
236
|
+
* fixed ABI constant, 100 on every architecture that matters. So the fallback
|
|
237
|
+
* below is the ABI and not a guess, and `getconf` is only belt and braces.
|
|
238
|
+
*
|
|
239
|
+
* A wrong value here fails SAFE in BOTH directions, which is why it is allowed
|
|
240
|
+
* to be a guess at all: too low and the process computes as far older than its
|
|
241
|
+
* lock (refused by the 120s window), too high and it computes as newer than a
|
|
242
|
+
* lock it supposedly wrote (refused by the 5s slack). */
|
|
243
|
+
function userHz() {
|
|
244
|
+
try {
|
|
245
|
+
const n = Number.parseInt(
|
|
246
|
+
execFileSync('getconf', ['CLK_TCK'], {
|
|
247
|
+
encoding: 'utf8',
|
|
248
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
249
|
+
timeout: 3000,
|
|
250
|
+
}).trim(),
|
|
251
|
+
10,
|
|
252
|
+
);
|
|
253
|
+
if (Number.isInteger(n) && n > 0 && n <= 1_000_000) return n;
|
|
254
|
+
} catch {
|
|
255
|
+
/* no getconf — the constant below IS the ABI */
|
|
256
|
+
}
|
|
257
|
+
return 100;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Wall-clock milliseconds at which `pid` started, or null.
|
|
262
|
+
*
|
|
263
|
+
* NULL IS NOT "UNKNOWN, PROBABLY FINE". Every caller must read it as refuse —
|
|
264
|
+
* this feeds a check that gates a SIGTERM and then a SIGKILL, and there is no
|
|
265
|
+
* such thing as a harmless guess about which process to kill.
|
|
266
|
+
*/
|
|
267
|
+
function processStartedAt(pid) {
|
|
268
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
269
|
+
try {
|
|
270
|
+
if (platform() === 'linux') {
|
|
271
|
+
// FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap. Field 2
|
|
272
|
+
// is `comm`, which the kernel wraps in parens and which may contain BOTH
|
|
273
|
+
// spaces and parens — it is the first 15 bytes of the executable's name,
|
|
274
|
+
// and a name is not a token. A naive split on whitespace taking $22 then
|
|
275
|
+
// lands on `nice` for any such process, and `nice` is a small integer, so
|
|
276
|
+
// the answer is not a parse error but a plausible-looking "started at
|
|
277
|
+
// boot" that sails past any isFinite guard. Measured: a binary named
|
|
278
|
+
// `ev (i l) x` read 28308s too old that way.
|
|
279
|
+
//
|
|
280
|
+
// Every field after `comm` is a number or a single-character state, so no
|
|
281
|
+
// ')' can appear later: the LAST ')' in the line is always the kernel's
|
|
282
|
+
// own closing paren, even when comm itself ends in one.
|
|
283
|
+
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
284
|
+
const close = raw.lastIndexOf(')');
|
|
285
|
+
if (close < 0) return null;
|
|
286
|
+
const fields = raw.slice(close + 1).trim().split(/\s+/); // fields[0] IS field 3
|
|
287
|
+
const ticks = Number.parseInt(fields[19], 10); // field 22 => 22 - 3
|
|
288
|
+
if (!Number.isFinite(ticks) || ticks < 0) return null;
|
|
289
|
+
// `/proc/uptime` rather than `/proc/stat`'s btime, and not for precision
|
|
290
|
+
// alone (measured 6ms out against 162ms). Date.now() and uptime are read
|
|
291
|
+
// at the same instant, so a realtime clock stepped since boot cancels
|
|
292
|
+
// out of the subtraction; btime bakes in a boot-realtime estimate that a
|
|
293
|
+
// later NTP step silently invalidates. `starttime` is in ticks either
|
|
294
|
+
// way — uptime gives seconds-since-boot, so the division by USER_HZ is
|
|
295
|
+
// not optional.
|
|
296
|
+
const up = Number.parseFloat(readFileSync('/proc/uptime', 'utf8').split(/\s+/)[0]);
|
|
297
|
+
if (!Number.isFinite(up) || up < 0) return null;
|
|
298
|
+
const ageMs = (up - ticks / userHz()) * 1000;
|
|
299
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return null;
|
|
300
|
+
return Date.now() - ageMs;
|
|
301
|
+
}
|
|
302
|
+
if (platform() === 'darwin') {
|
|
303
|
+
// `etime`, not `lstart`. A DURATION has no locale, no timezone, and no
|
|
304
|
+
// ambiguous repeated hour at the DST fall-back — where `lstart` is an
|
|
305
|
+
// hour out and would refuse a genuine holder twice a year. It also dodges
|
|
306
|
+
// Date.parse being LENIENT rather than strict: a localized `lstart` can
|
|
307
|
+
// parse to a wrong-but-finite instant instead of failing honestly.
|
|
308
|
+
// (`etimes`, the seconds-only form, is procps-only and not a BSD keyword.)
|
|
309
|
+
// Both derive from the same kinfo_proc.p_starttime, so 1s resolution
|
|
310
|
+
// against a 120s window costs nothing. LC_ALL is pinned anyway, since a
|
|
311
|
+
// localized number format would be a wrong answer rather than no answer.
|
|
312
|
+
const out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], {
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
315
|
+
timeout: 3000,
|
|
316
|
+
env: { ...process.env, LC_ALL: 'C', LC_TIME: 'C' },
|
|
317
|
+
});
|
|
318
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(out.trim()); // [[dd-]hh:]mm:ss
|
|
319
|
+
if (!m) return null;
|
|
320
|
+
const age =
|
|
321
|
+
Number(m[1] || 0) * 86400 + Number(m[2] || 0) * 3600 + Number(m[3]) * 60 + Number(m[4]);
|
|
322
|
+
if (!Number.isFinite(age) || age < 0) return null;
|
|
323
|
+
return Date.now() - age * 1000;
|
|
324
|
+
}
|
|
325
|
+
return null; // win32, and anything else — never signalled rather than guessed at
|
|
326
|
+
} catch {
|
|
327
|
+
return null; // no /proc (hidepid=2, a pid namespace, a stripped container), no ps
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* THE FALLBACK, for a lock that carries no `entry`.
|
|
333
|
+
*
|
|
334
|
+
* `entry` arrived after the lock did. Every daemon from 0.51.2 through 0.53.0
|
|
335
|
+
* wrote `{pid, repoRoot, startedAt}` and nothing else, and stillTheHolder used
|
|
336
|
+
* to refuse those outright. Refusing was right in spirit and useless in fact:
|
|
337
|
+
* it made takeover impossible on precisely the locks takeover exists for, since
|
|
338
|
+
* the daemon you are replacing is by definition the OLD one. `--takeover` did
|
|
339
|
+
* nothing, and re-running `flowviant` in the repo you were working in printed a
|
|
340
|
+
* refusal and sent you hunting for a pid — the exact ceremony the same-repo rule
|
|
341
|
+
* was written to abolish. (Those locks carry no `version` either, so a legacy
|
|
342
|
+
* takeover also skips takeOverFrom's downgrade guard, which short-circuits on
|
|
343
|
+
* `holder.version &&`. Separate hole, not this function's to close.)
|
|
344
|
+
*
|
|
345
|
+
* WHY START TIME PROVES IDENTITY, which is the only question worth asking here,
|
|
346
|
+
* because what this gates is a SIGTERM and then a SIGKILL. A pid alone proves
|
|
347
|
+
* nothing: pids are recycled, and a crashed daemon's number goes to whatever
|
|
348
|
+
* forks next. The PAIR (pid, start time) is the standard POSIX process
|
|
349
|
+
* identity — pidfd, systemd and procps all key on it — because the kernel
|
|
350
|
+
* stamps a start time at fork and it is immutable for the life of the process,
|
|
351
|
+
* unforgeable by anything that started later. And `startedAt` was written BY
|
|
352
|
+
* the process being identified, measured 29-32ms after its own fork, so the
|
|
353
|
+
* lock is that process's own witness to when it began.
|
|
354
|
+
*
|
|
355
|
+
* It is a WEAKER statement than `entry`, which says what the process IS rather
|
|
356
|
+
* than when it started, and that is why `entry` stays the primary path. What
|
|
357
|
+
* makes this one acceptable anyway is that a false positive needs two things at
|
|
358
|
+
* once that are close to mutually exclusive: the pid space must have wrapped
|
|
359
|
+
* back to this exact number, AND the new occupant must have started inside a
|
|
360
|
+
* 2-minute window that ENDS at the lock write. Wrapping takes millions of
|
|
361
|
+
* forks; the window ends before the impostor could have been born.
|
|
362
|
+
*
|
|
363
|
+
* EVERY ERROR MODE HERE PUSHES TOWARD REFUSAL, never toward signalling — an
|
|
364
|
+
* unreadable /proc, a pid namespace, a kernel before 5.5 whose starttime drifts
|
|
365
|
+
* across suspend, a realtime clock stepped in either direction, a locale that
|
|
366
|
+
* mangles `ps`, Windows. All of them return false, and false costs a person a
|
|
367
|
+
* manual kill. The other direction costs somebody else's process.
|
|
368
|
+
*/
|
|
369
|
+
function startedAroundLockWrite(holder) {
|
|
370
|
+
try {
|
|
371
|
+
const written = Date.parse(holder?.startedAt ?? '');
|
|
372
|
+
if (!Number.isFinite(written)) return null; // no witness — nothing was measured
|
|
373
|
+
const started = processStartedAt(holder.pid);
|
|
374
|
+
if (started === null) return null; // could not measure — see the tri-state note
|
|
375
|
+
const delta = written - started; // >0: the process predates its own lock line, as it must
|
|
376
|
+
return delta >= -TAKEOVER_START_SLACK_MS && delta <= TAKEOVER_START_WINDOW_MS;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
200
382
|
/**
|
|
201
383
|
* IS THIS PID STILL THE DAEMON THAT TOOK THE LOCK?
|
|
202
384
|
*
|
|
@@ -207,23 +389,86 @@ export function daemonInSameRepo(repoRoot, ownPath) {
|
|
|
207
389
|
* runner living under a `…-flowviant/` directory. That last one is not
|
|
208
390
|
* hypothetical — a looser version of this check SIGTERMed one.
|
|
209
391
|
*
|
|
210
|
-
* A lock with no `entry`
|
|
392
|
+
* A lock with no `entry` is no longer refused outright. Those locks are real
|
|
393
|
+
* and current — every daemon 0.51.2 through 0.53.0 wrote one — so refusing
|
|
394
|
+
* them meant takeover never worked on the upgrade it was built for. They fall
|
|
395
|
+
* back to the holder's PROCESS START TIME, which is the same claim made a
|
|
396
|
+
* weaker way; startedAroundLockWrite above argues why that is a proof rather
|
|
397
|
+
* than a guess, and why every way it can go wrong ends in a refusal.
|
|
398
|
+
*
|
|
399
|
+
* TRI-STATE, and the third value is the point: `true` identified, `false`
|
|
400
|
+
* measured-and-it-is-someone-else, `null` COULD NOT MEASURE. Both `false` and
|
|
401
|
+
* `null` refuse — that never changes — but they are not the same sentence, and
|
|
402
|
+
* collapsing them made the refusal assert a fact nobody had established: on a
|
|
403
|
+
* `hidepid=2` host (ordinary Debian/Ubuntu hardening) the daemon told the user
|
|
404
|
+
* their live holder "is no longer the daemon that took this lock", i.e. that
|
|
405
|
+
* the lock was stale. Acting on that — deleting the lock, or taking the
|
|
406
|
+
* ALLOW_MULTI escape printed underneath it — lands them in two daemons in one
|
|
407
|
+
* working tree, which this module's header says no server lease can arbitrate.
|
|
408
|
+
* Ignorance is not a state this product renders as fact.
|
|
211
409
|
*/
|
|
212
410
|
function stillTheHolder(holder) {
|
|
213
411
|
const want = typeof holder?.entry === 'string' ? holder.entry : null;
|
|
214
|
-
|
|
412
|
+
// An `entry` of '' is a field with nothing in it — record() writes
|
|
413
|
+
// `process.argv[1] || ''` — and matching on '' would match every process
|
|
414
|
+
// alive, so it takes the same road as a missing one.
|
|
415
|
+
if (!want) return startedAroundLockWrite(holder);
|
|
416
|
+
let cmdline;
|
|
215
417
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
418
|
+
cmdline =
|
|
419
|
+
platform() === 'linux'
|
|
420
|
+
? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
|
|
421
|
+
: execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
422
|
+
encoding: 'utf8',
|
|
423
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
424
|
+
timeout: 3000,
|
|
425
|
+
});
|
|
426
|
+
} catch {
|
|
427
|
+
// NOT `false`. takeOverFrom already returned early if the pid were gone, so
|
|
428
|
+
// reaching here means the process is alive and we could not READ it —
|
|
429
|
+
// hidepid=2, a pid namespace, a stripped image with no `ps`. Saying `false`
|
|
430
|
+
// here is what made the refusal claim the pid belonged to somebody else.
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (!cmdline.includes(want)) return false;
|
|
434
|
+
// AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
|
|
435
|
+
// entry path under a global install, so "cmdline contains this cli.mjs"
|
|
436
|
+
// proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
|
|
437
|
+
// lock" — and a crashed daemon's pid recycled to a SIBLING project's live
|
|
438
|
+
// daemon passed it, which let a same-repo takeover SIGTERM a different
|
|
439
|
+
// project's machine. Every 0.54.0+ lock also carries `startedAt`, the
|
|
440
|
+
// process's own witness to when it began, so when it is present the start
|
|
441
|
+
// time must agree too. `null` (could not measure — hidepid, no ps, a lock
|
|
442
|
+
// with no startedAt) falls back to the entry match alone, exactly the
|
|
443
|
+
// pre-check behaviour: refusing on ignorance here would re-brick takeover
|
|
444
|
+
// on the hosts that hide /proc.
|
|
445
|
+
const around = startedAroundLockWrite(holder);
|
|
446
|
+
return around === false ? false : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Unlink a lock file ONLY while it still names the pid the caller decided
|
|
451
|
+
* about (or nothing readable). Every rmSync of a lock outside the ppid-adopt
|
|
452
|
+
* path goes through this: between "I proved pid N is dead/stale" and the
|
|
453
|
+
* unlink, a concurrently starting daemon can clear the file itself and
|
|
454
|
+
* wx-create its own — and an unconditional rm then deletes a LIVE daemon's
|
|
455
|
+
* lock, leaving it running unguarded, which is the one condition this module
|
|
456
|
+
* exists to prevent. The read-then-rm gap that remains is microseconds against
|
|
457
|
+
* the seconds-wide window it closes.
|
|
458
|
+
*
|
|
459
|
+
* Returns false when the file now names a DIFFERENT pid — a handover the
|
|
460
|
+
* caller must treat as "not mine to clear" — true otherwise (removed, already
|
|
461
|
+
* gone, or best-effort failed into acquire's next pass).
|
|
462
|
+
*/
|
|
463
|
+
function rmLockIfStill(path, pid) {
|
|
464
|
+
const cur = readHolder(path);
|
|
465
|
+
if (cur && cur.pid !== pid) return false;
|
|
466
|
+
try {
|
|
467
|
+
rmSync(path, { force: true });
|
|
224
468
|
} catch {
|
|
225
|
-
|
|
469
|
+
/* best-effort; a stale file is cleared by the next acquire */
|
|
226
470
|
}
|
|
471
|
+
return true;
|
|
227
472
|
}
|
|
228
473
|
|
|
229
474
|
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
@@ -242,7 +487,16 @@ const sleep = (ms) => {
|
|
|
242
487
|
const TAKEOVER_GRACE_MS = 20_000;
|
|
243
488
|
|
|
244
489
|
/**
|
|
245
|
-
*
|
|
490
|
+
* THE STAND-DOWN, and it is deliberately ONE copy of this.
|
|
491
|
+
*
|
|
492
|
+
* Two callers perform this identical ritual for different reasons — a takeover
|
|
493
|
+
* (a second run in the same repo replacing the daemon serving it) and
|
|
494
|
+
* `flowviant stop` (a person who does not know what is running clearing the
|
|
495
|
+
* box). What they share is a SIGTERM followed by a SIGKILL, and two hand-copies
|
|
496
|
+
* of a SIGKILL are two places to get the grace period, the zombie trap or the
|
|
497
|
+
* mid-update handover subtly wrong. The IDENTITY gate is NOT in here: this
|
|
498
|
+
* function signals whatever it is handed, so every caller must have proved what
|
|
499
|
+
* the pid is before it calls (see stillTheHolder, and read its tri-state note).
|
|
246
500
|
*
|
|
247
501
|
* SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
|
|
248
502
|
* teardown — it kills the CLI children it spawned and stops its preview
|
|
@@ -259,18 +513,24 @@ const TAKEOVER_GRACE_MS = 20_000;
|
|
|
259
513
|
* because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
|
|
260
514
|
* this same lock through the ppid branch. Treating that as free steals a live
|
|
261
515
|
* daemon's lock and leaves it running unguarded — measured doing exactly that.
|
|
516
|
+
*
|
|
517
|
+
* Returns null when the holder is stopped and its lock file is cleared, or
|
|
518
|
+
* `{ failed }` with a sentence the caller can print as-is.
|
|
262
519
|
*/
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
520
|
+
function standDown(holder, path, log) {
|
|
521
|
+
// NEVER SIGNAL OURSELVES, and this is not a theoretical guard — it was
|
|
522
|
+
// reproduced end to end. A 0.54.0+ lock records `entry` = the daemon's
|
|
523
|
+
// argv[1], i.e. `…/bin/cli.mjs`. Run `flowviant stop` and OUR cmdline is
|
|
524
|
+
// `node …/bin/cli.mjs stop`, which CONTAINS that string. So if a stale lock's
|
|
525
|
+
// pid has been recycled to us — ordinary on a host with pid_max 32768, and
|
|
526
|
+
// stale locks are deliberately left on disk — stillTheHolder answers `true`
|
|
527
|
+
// ABOUT THE SWEEPER and this function SIGTERMs the process running it. The
|
|
528
|
+
// command then dies mid-line, every later lock goes unexamined, and the real
|
|
529
|
+
// daemons it was asked to stop keep running. It is the signal-the-wrong-
|
|
530
|
+
// process bug arriving through a POSITIVE identification, which is why the
|
|
531
|
+
// identity check cannot catch it and the guard belongs here, at the one place
|
|
532
|
+
// that signals, rather than in each caller.
|
|
533
|
+
if (holder.pid === process.pid) return { failed: 'refusing to signal this very process' };
|
|
274
534
|
log?.(`asking daemon pid ${holder.pid} to stand down…`);
|
|
275
535
|
try {
|
|
276
536
|
process.kill(holder.pid, 'SIGTERM');
|
|
@@ -310,12 +570,56 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
|
310
570
|
sleep(400);
|
|
311
571
|
}
|
|
312
572
|
|
|
313
|
-
// A SIGKILLed daemon never ran its release(), so clear what it left
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return { failed: '
|
|
573
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left — but
|
|
574
|
+
// only if the file still names the pid we stood down: in the gap since the
|
|
575
|
+
// last read a fresh daemon may have cleared it and taken the lock itself.
|
|
576
|
+
if (!rmLockIfStill(path, holder.pid)) {
|
|
577
|
+
return { failed: 'another daemon took the lock while it was being cleared — try again in a moment' };
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Ask the holder to stand down, then take its place.
|
|
584
|
+
*
|
|
585
|
+
* WHAT THIS FUNCTION IS, now that the choreography lives in standDown above: the
|
|
586
|
+
* IDENTITY GATE. Nothing below signals anything until the pid has been proved to
|
|
587
|
+
* be the process that wrote this lock — a lock records a PID, pids are recycled,
|
|
588
|
+
* and a crashed daemon's number goes to whatever forks next. `stillTheHolder` is
|
|
589
|
+
* tri-state and both of its refusing values are reported, separately, because
|
|
590
|
+
* "it is someone else" and "we could not look" are different sentences and
|
|
591
|
+
* collapsing them once had the daemon telling people a live holder was stale.
|
|
592
|
+
*/
|
|
593
|
+
function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
594
|
+
if (!holder?.pid || !alive(holder.pid)) return null; // already gone
|
|
595
|
+
const identified = stillTheHolder(holder);
|
|
596
|
+
if (identified === null) {
|
|
597
|
+
// We know nothing about this pid, and said so. The remedy is a human
|
|
598
|
+
// stopping it, NOT running a second daemon alongside it.
|
|
599
|
+
return {
|
|
600
|
+
failed:
|
|
601
|
+
`cannot confirm what pid ${holder.pid} is on this host — no readable /proc or ps — ` +
|
|
602
|
+
`so it will not be signalled. Stop that process yourself and start this one again.`,
|
|
603
|
+
unidentified: true,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (identified === false) {
|
|
607
|
+
// MEASURED: the lock's writer is gone and the pid now belongs to something
|
|
608
|
+
// else. That is a STALE LOCK, not an unremovable holder — refusing here
|
|
609
|
+
// used to brick every start after an OOM-kill or reboot recycled the pid
|
|
610
|
+
// to any live process, until a human deleted ~/.flowviant/daemon-*.lock by
|
|
611
|
+
// hand. Nothing is signalled (the process is a stranger); the caller
|
|
612
|
+
// clears the corpse the same way it clears a dead pid's.
|
|
613
|
+
return { stale: true };
|
|
318
614
|
}
|
|
615
|
+
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
616
|
+
return {
|
|
617
|
+
failed: `the running daemon is ${holder.version} and this one is ${VERSION} — refusing to replace a newer daemon with an older one (--takeover-downgrade if you mean it)`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const bad = standDown(holder, path, log);
|
|
622
|
+
if (bad) return bad;
|
|
319
623
|
log?.(`daemon pid ${holder.pid} stopped — taking over.`);
|
|
320
624
|
return null;
|
|
321
625
|
}
|
|
@@ -350,8 +654,19 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
350
654
|
// quietly.
|
|
351
655
|
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
352
656
|
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
353
|
-
|
|
354
|
-
|
|
657
|
+
// The OPTIONS ride along — this call used to drop them, so a deliberate
|
|
658
|
+
// `flowviant --takeover-downgrade` against a newer neighbour printed
|
|
659
|
+
// "--takeover-downgrade if you mean it" at somebody who had already
|
|
660
|
+
// typed it.
|
|
661
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log, { allowDowngrade });
|
|
662
|
+
if (bad?.stale) {
|
|
663
|
+
// The neighbour's lock is a corpse wearing a recycled pid — clear it (if
|
|
664
|
+
// it still names that pid) and carry on to our own lock.
|
|
665
|
+
log?.(`pid ${neighbour.pid} is no longer a daemon — clearing its stale lock.`);
|
|
666
|
+
rmLockIfStill(neighbourLockPath(neighbour, path), neighbour.pid);
|
|
667
|
+
} else if (bad) {
|
|
668
|
+
return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
|
|
669
|
+
}
|
|
355
670
|
}
|
|
356
671
|
|
|
357
672
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
@@ -364,11 +679,20 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
364
679
|
if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
|
|
365
680
|
const holder = readHolder(path);
|
|
366
681
|
if (!holder || !alive(holder.pid)) {
|
|
367
|
-
// A crashed daemon's leftover. Clear it and take it on the next pass
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
682
|
+
// A crashed daemon's leftover. Clear it and take it on the next pass —
|
|
683
|
+
// ownership-verified, because a concurrent start may have cleared and
|
|
684
|
+
// re-created it in the gap since our read.
|
|
685
|
+
if (holder) rmLockIfStill(path, holder.pid);
|
|
686
|
+
else {
|
|
687
|
+
// Unreadable content: re-read before clearing, so a half-written
|
|
688
|
+
// record a peer is writing RIGHT NOW is not deleted mid-write.
|
|
689
|
+
const again = readHolder(path);
|
|
690
|
+
if (again && alive(again.pid)) continue; // it finished writing — a real holder now
|
|
691
|
+
try {
|
|
692
|
+
rmSync(path, { force: true });
|
|
693
|
+
} catch {
|
|
694
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
695
|
+
}
|
|
372
696
|
}
|
|
373
697
|
continue;
|
|
374
698
|
}
|
|
@@ -394,9 +718,28 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
394
718
|
const wanted = force || (here && !noTakeover);
|
|
395
719
|
if (wanted) {
|
|
396
720
|
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
397
|
-
if (bad)
|
|
721
|
+
if (bad?.stale) {
|
|
722
|
+
// Measured: the lock's writer is gone and its pid was recycled to a
|
|
723
|
+
// stranger. A corpse is cleared, never "refused" — refusing bricked
|
|
724
|
+
// every start after a reboot handed the pid to any live process.
|
|
725
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
726
|
+
rmLockIfStill(path, holder.pid);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (bad)
|
|
730
|
+
return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
|
|
398
731
|
continue; // the file is gone — the next pass takes it
|
|
399
732
|
}
|
|
733
|
+
// Before refusing on a different-repo holder, make sure it IS one: a
|
|
734
|
+
// stale lock whose pid was recycled to any live process would otherwise
|
|
735
|
+
// refuse this credential's start forever, naming a "daemon" that is a
|
|
736
|
+
// stranger. Only the MEASURED verdict clears; null (could not look)
|
|
737
|
+
// still refuses, because ignorance must not delete a lock.
|
|
738
|
+
if (stillTheHolder(holder) === false) {
|
|
739
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
740
|
+
rmLockIfStill(path, holder.pid);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
400
743
|
return { ok: false, holder, sameRepo: here };
|
|
401
744
|
}
|
|
402
745
|
try {
|
|
@@ -429,3 +772,138 @@ function makeRelease(path) {
|
|
|
429
772
|
process.on('exit', release);
|
|
430
773
|
return release;
|
|
431
774
|
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* STOP EVERY FLOWVIANT DAEMON ON THIS MACHINE — `flowviant stop`.
|
|
778
|
+
*
|
|
779
|
+
* WHY IT IS "EVERY" AND NOT "THIS REPO'S". The friction this exists to remove is
|
|
780
|
+
* not knowing what is running. Somebody who could NAME the daemon they meant
|
|
781
|
+
* would not need this command — they would already have the pid. What actually
|
|
782
|
+
* happens is that they run `flowviant`, hit a refusal naming a pid and a
|
|
783
|
+
* directory they do not recognise, and go hunting through `ps`. So this takes no
|
|
784
|
+
* argument, asks no question, and sweeps every credential's lock file rather
|
|
785
|
+
* than the one this checkout happens to key to: a stop command with a scope is a
|
|
786
|
+
* stop command you have to be sure about before you can use it.
|
|
787
|
+
*
|
|
788
|
+
* IT IDENTIFIES BEFORE IT SIGNALS, exactly as takeover does and for the same
|
|
789
|
+
* reason — a lock records a PID, pids are recycled, and there is no such thing
|
|
790
|
+
* as a harmless guess about which process to kill. The tri-state from
|
|
791
|
+
* stillTheHolder is reported as three DIFFERENT sentences and never collapsed:
|
|
792
|
+
*
|
|
793
|
+
* true -> stopped, through the same standDown the takeover path uses.
|
|
794
|
+
* false -> measured, and that pid is somebody else now. Nothing is signalled
|
|
795
|
+
* and it is NOT a failure: the daemon that wrote the lock is gone,
|
|
796
|
+
* which is the answer the asker wanted.
|
|
797
|
+
* null -> COULD NOT CONFIRM — either the lock carries no witness to match
|
|
798
|
+
* against, or the host hides the process (hidepid=2, a pid
|
|
799
|
+
* namespace, an image with no `ps`); the line below names BOTH,
|
|
800
|
+
* since we did not measure which. Said out loud WITH THE PID,
|
|
801
|
+
* because we have just declined to touch a live process and the
|
|
802
|
+
* remedy is a human running `kill`.
|
|
803
|
+
* That one counts as a failure — something is alive and we did not
|
|
804
|
+
* stop it — which is the ONLY thing that makes this command exit
|
|
805
|
+
* non-zero.
|
|
806
|
+
*
|
|
807
|
+
* A STALE LOCK IS LEFT ON DISK. Unlinking one looks tidy and races a daemon that
|
|
808
|
+
* is starting RIGHT NOW: acquire clears a dead holder's file and then re-creates
|
|
809
|
+
* it with `wx`, so a sweep landing between those two steps deletes a LIVE
|
|
810
|
+
* daemon's lock and leaves it running unguarded — the one condition this whole
|
|
811
|
+
* module exists to prevent. Clearing stale files is acquire's job, it already
|
|
812
|
+
* does it, and it does it without the race.
|
|
813
|
+
*
|
|
814
|
+
* FINDING NOTHING IS THE FEATURE, not an error: "no flowviant daemon is running
|
|
815
|
+
* on this machine." is the sentence the person who did not know came for, and it
|
|
816
|
+
* exits 0. Reported through `log` line by line as the sweep goes — a person
|
|
817
|
+
* watching a SIGTERM wants to see which pid it went to while it is happening,
|
|
818
|
+
* not in a summary afterwards.
|
|
819
|
+
*
|
|
820
|
+
* Returns `{ stopped, unconfirmed, failed }`; the caller turns `failed` into the
|
|
821
|
+
* exit code.
|
|
822
|
+
*/
|
|
823
|
+
export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
|
|
824
|
+
let stopped = 0;
|
|
825
|
+
let unconfirmed = 0;
|
|
826
|
+
let failed = 0;
|
|
827
|
+
let running = 0; // locks naming a process we believe is, or might be, a daemon
|
|
828
|
+
|
|
829
|
+
for (const path of lockFiles()) {
|
|
830
|
+
const holder = readHolder(path);
|
|
831
|
+
if (!holder) continue; // absent, truncated, half-written — no claim to answer
|
|
832
|
+
const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
|
|
833
|
+
const what = holder.version ? ` ${holder.version}` : '';
|
|
834
|
+
|
|
835
|
+
if (!alive(holder.pid)) {
|
|
836
|
+
log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
// Us. standDown refuses this too, but reaching it would print a stand-down
|
|
840
|
+
// line and then a failure for the one pid we are certain is not a daemon.
|
|
841
|
+
if (holder.pid === process.pid) continue;
|
|
842
|
+
|
|
843
|
+
const identified = stillTheHolder(holder);
|
|
844
|
+
if (identified === false) {
|
|
845
|
+
// A live pid, but measured NOT to be the process that wrote this lock. The
|
|
846
|
+
// daemon is gone; the number was handed to something else. Not counted as
|
|
847
|
+
// running, and deliberately not counted as a failure either.
|
|
848
|
+
log(`pid ${holder.pid} is no longer the daemon that took this lock — nothing signalled.`);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
running++;
|
|
852
|
+
if (identified === null) {
|
|
853
|
+
unconfirmed++;
|
|
854
|
+
failed++;
|
|
855
|
+
// NAMES BOTH CAUSES, because null has two and we did not measure which:
|
|
856
|
+
// the lock may carry no witness to match against (neither `entry` nor
|
|
857
|
+
// `startedAt` — a hand-edited or half-written file), or this host may hide
|
|
858
|
+
// the process from us (hidepid=2, a pid namespace, an image with no `ps`).
|
|
859
|
+
// "no readable /proc" alone is what takeover says, and said HERE it would
|
|
860
|
+
// assert a diagnosis nobody established — over a live process we are about
|
|
861
|
+
// to tell someone to kill.
|
|
862
|
+
// `kill` is not a command on Windows, where platform() is 'win32' and
|
|
863
|
+
// processStartedAt has no implementation at all — so EVERY lock lands in
|
|
864
|
+
// this branch and the whole command is a no-op that exits 1. Say that
|
|
865
|
+
// once, in the platform's own vocabulary, rather than handing someone a
|
|
866
|
+
// remedy their shell does not have.
|
|
867
|
+
const byHand =
|
|
868
|
+
platform() === 'win32'
|
|
869
|
+
? `taskkill /PID ${holder.pid} /F`
|
|
870
|
+
: `kill ${holder.pid}`;
|
|
871
|
+
log(
|
|
872
|
+
`could not confirm that pid ${holder.pid} is still a flowviant daemon — its lock carries ` +
|
|
873
|
+
`nothing to match it against, or this host hides the process from us` +
|
|
874
|
+
`${platform() === 'win32' ? ' (identifying a process is not implemented on Windows)' : ''}` +
|
|
875
|
+
` — so it was NOT signalled. Stop it by hand: ${byHand}`
|
|
876
|
+
);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const bad = standDown(holder, path, log);
|
|
881
|
+
if (bad) {
|
|
882
|
+
failed++;
|
|
883
|
+
log(`could not stop daemon pid ${holder.pid}${where}: ${bad.failed}`);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
stopped++;
|
|
887
|
+
log(`stopped daemon${what} pid ${holder.pid}${where}.`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
|
|
891
|
+
// sentence — "no flowviant daemon is running on this machine" — was asserted
|
|
892
|
+
// from lock files alone, and there are several ways to run a daemon that
|
|
893
|
+
// holds no readable lock: FLOWVIANT_ALLOW_MULTI=1 returns before the
|
|
894
|
+
// filesystem is touched (and fleet.mjs PRINTS that flag as the way out of an
|
|
895
|
+
// "already running" refusal, so a stuck user is steered straight onto it), an
|
|
896
|
+
// unwritable ~/.flowviant runs `unguarded`, and every daemon before 0.51.2
|
|
897
|
+
// predates the lock entirely. Telling somebody "nothing is running" while
|
|
898
|
+
// something is, is this product's cardinal sin: it turns ignorance into a
|
|
899
|
+
// state. So the claim is scoped to what was looked at, and the ways past it
|
|
900
|
+
// are named rather than left for them to discover.
|
|
901
|
+
if (!running) {
|
|
902
|
+
log('no flowviant daemon holds a lock on this machine.');
|
|
903
|
+
log(
|
|
904
|
+
'(a daemon started with FLOWVIANT_ALLOW_MULTI=1, or one older than 0.51.2, holds no lock — ' +
|
|
905
|
+
'this cannot see those. `pgrep -af flowviant` will.)'
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return { stopped, unconfirmed, failed };
|
|
909
|
+
}
|
package/bin/lib/login.mjs
CHANGED
|
@@ -74,7 +74,11 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
74
74
|
continue; // transient — keep polling
|
|
75
75
|
}
|
|
76
76
|
if (poll.status === 'approved') {
|
|
77
|
-
|
|
77
|
+
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
78
|
+
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|
|
79
|
+
// THIS release (0.54.2) — reading both here is what makes retiring the
|
|
80
|
+
// old key possible at all.
|
|
81
|
+
store({ fleetToken: poll.machineToken ?? poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
|
|
78
82
|
ok('connected — credential saved to ~/.flowviant/credentials.json');
|
|
79
83
|
// The daemon starts right here unless the caller opted out; telling
|
|
80
84
|
// someone to run a second command was the step that got missed, since by
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -228,9 +228,25 @@ function mutateRegistry(fn) {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
|
|
232
|
+
function processAlive(pid) {
|
|
233
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, 0);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return e.code === 'EPERM';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
function recordPreviewPid(pid, sig) {
|
|
232
243
|
if (!pid) return;
|
|
233
|
-
|
|
244
|
+
// `owner` is the DAEMON that spawned it. The registry is shared by design —
|
|
245
|
+
// two daemons serving two projects both write here — so without an owner a
|
|
246
|
+
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
247
|
+
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
248
|
+
// the origin port, which was still alive).
|
|
249
|
+
mutateRegistry((list) => [...list, { pid, sig, owner: process.pid }]);
|
|
234
250
|
}
|
|
235
251
|
|
|
236
252
|
function forgetPreviewPid(pid) {
|
|
@@ -253,12 +269,21 @@ function stillOurs(pid, sig) {
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
256
|
-
* Call once at daemon startup, before any work begins.
|
|
272
|
+
* Call once at daemon startup, before any work begins.
|
|
273
|
+
*
|
|
274
|
+
* ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
|
|
275
|
+
* peer serving another project (or to the process we are replacing, whose
|
|
276
|
+
* own teardown handles it) — killing those and wiping their entries was a
|
|
277
|
+
* peer daemon's startup silently breaking every live share on the box. Only
|
|
278
|
+
* the entries this pass handled are removed; a peer's records survive. */
|
|
257
279
|
export function reapOrphanPreviews(log) {
|
|
258
280
|
const list = readRegistry();
|
|
259
281
|
if (list.length === 0) return;
|
|
260
282
|
let killed = 0;
|
|
261
|
-
|
|
283
|
+
const handled = new Set();
|
|
284
|
+
for (const { pid, sig, owner } of list) {
|
|
285
|
+
if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
|
|
286
|
+
handled.add(pid);
|
|
262
287
|
if (!stillOurs(pid, sig)) continue;
|
|
263
288
|
try {
|
|
264
289
|
process.kill(-pid, 'SIGKILL'); // whole group
|
|
@@ -272,7 +297,7 @@ export function reapOrphanPreviews(log) {
|
|
|
272
297
|
}
|
|
273
298
|
}
|
|
274
299
|
}
|
|
275
|
-
mutateRegistry(() =>
|
|
300
|
+
if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
276
301
|
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
|
277
302
|
}
|
|
278
303
|
|
|
@@ -295,8 +320,35 @@ const TAIL_BYTES = 2000;
|
|
|
295
320
|
* cloudflared happily outlives a dead dev server and the gate answers a dead
|
|
296
321
|
* origin with 502, so without this the product would report "live" over a 502 —
|
|
297
322
|
* Flowviant asserting a state it never measured.
|
|
323
|
+
*
|
|
324
|
+
* `stillServing` (optional, async → boolean) is the ATTRIBUTION re-check the
|
|
325
|
+
* probe runs instead of a bare TCP connect. Ports are global to a box and a
|
|
326
|
+
* worktree is not: when the driver's dev server dies and anything else — a
|
|
327
|
+
* teammate's worktree, a database — binds the same number, a bare
|
|
328
|
+
* `isListening` keeps the probe green and the existing URL+password serve the
|
|
329
|
+
* NEW process, outside every consent gate. The caller passes the same
|
|
330
|
+
* `listenersIn(worktree)` check the open path uses, so "the origin is alive"
|
|
331
|
+
* keeps meaning "THIS session's origin".
|
|
332
|
+
*
|
|
333
|
+
* `onAbuse` fires when the gate closes itself after repeated failed password
|
|
334
|
+
* attempts — AFTER the share is torn down locally — so the caller can report
|
|
335
|
+
* the incident. Without it the abuse close was invisible: the row kept
|
|
336
|
+
* reading "live" until staleness, and endedReason 'abuse' was unreachable.
|
|
337
|
+
*
|
|
338
|
+
* `onTunnelGone` fires when cloudflared exits AFTER the URL was published
|
|
339
|
+
* (quick tunnels are best-effort and do get dropped). The probe cannot see
|
|
340
|
+
* this — it watches the origin — and a daemon that keeps heartbeating a dead
|
|
341
|
+
* hostname confirms "live" over a 530 for up to 8 hours.
|
|
298
342
|
*/
|
|
299
|
-
export async function openTunnel({
|
|
343
|
+
export async function openTunnel({
|
|
344
|
+
port,
|
|
345
|
+
log,
|
|
346
|
+
onDead,
|
|
347
|
+
onAbuse,
|
|
348
|
+
onTunnelGone,
|
|
349
|
+
stillServing,
|
|
350
|
+
probeMs = 20_000,
|
|
351
|
+
}) {
|
|
300
352
|
// Re-validate at the machine. The server checked this port against the last
|
|
301
353
|
// report; reports are up to a minute old and a dev server is a process a
|
|
302
354
|
// human can stop at any moment.
|
|
@@ -337,7 +389,18 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
337
389
|
|
|
338
390
|
// The gate comes up FIRST and the tunnel points at it, never at the origin —
|
|
339
391
|
// so there is no window in which the public hostname is un-gated.
|
|
340
|
-
gate = await startAuthProxy({
|
|
392
|
+
gate = await startAuthProxy({
|
|
393
|
+
targetPort: port,
|
|
394
|
+
log,
|
|
395
|
+
onAbuse: () => {
|
|
396
|
+
stop();
|
|
397
|
+
try {
|
|
398
|
+
onAbuse?.();
|
|
399
|
+
} catch {
|
|
400
|
+
/* the caller's report is best-effort */
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
341
404
|
if (!gate) {
|
|
342
405
|
return { error: 'could not start the password gate for this preview, so nothing was published.' };
|
|
343
406
|
}
|
|
@@ -348,7 +411,11 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
348
411
|
args.push('--http-host-header', 'localhost');
|
|
349
412
|
|
|
350
413
|
tunnel = spawn(cf.bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
351
|
-
|
|
414
|
+
// The signature names THIS tunnel's gate port, not the bare word
|
|
415
|
+
// 'cloudflared': the reap matches cmdline.includes(sig), and the generic
|
|
416
|
+
// word would let a recycled pid land on an operator's own unrelated
|
|
417
|
+
// cloudflared and group-SIGKILL it.
|
|
418
|
+
recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
|
|
352
419
|
|
|
353
420
|
return new Promise((resolve) => {
|
|
354
421
|
let settled = false;
|
|
@@ -378,11 +445,19 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
378
445
|
const m = TUNNEL_RE.exec(s);
|
|
379
446
|
if (!m) return;
|
|
380
447
|
|
|
381
|
-
// Watch the ORIGIN
|
|
382
|
-
//
|
|
448
|
+
// Watch the ORIGIN — with the caller's ATTRIBUTION check when it gave
|
|
449
|
+
// one, never a bare port probe: a freed port rebound by another
|
|
450
|
+
// worktree answers a TCP connect exactly like the origin did, and the
|
|
451
|
+
// share would keep serving a process nobody consented to publish.
|
|
383
452
|
probe = setInterval(async () => {
|
|
384
453
|
if (stopped) return;
|
|
385
|
-
|
|
454
|
+
let serving;
|
|
455
|
+
try {
|
|
456
|
+
serving = stillServing ? await stillServing() : await isListening(port);
|
|
457
|
+
} catch {
|
|
458
|
+
serving = false; // an attribution check that errors is not a "yes"
|
|
459
|
+
}
|
|
460
|
+
if (!serving) {
|
|
386
461
|
const dead = onDead;
|
|
387
462
|
stop();
|
|
388
463
|
try {
|
|
@@ -394,6 +469,20 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
394
469
|
}, probeMs);
|
|
395
470
|
if (probe.unref) probe.unref();
|
|
396
471
|
|
|
472
|
+
// The TUNNEL dying after publish (quick tunnels get dropped) is the one
|
|
473
|
+
// exit the probe cannot see. `stopped` guards our own kill: stop() sets
|
|
474
|
+
// it before signalling, so this only fires for a death nobody asked for.
|
|
475
|
+
tunnel.once('close', () => {
|
|
476
|
+
if (stopped) return;
|
|
477
|
+
const gone = onTunnelGone;
|
|
478
|
+
stop();
|
|
479
|
+
try {
|
|
480
|
+
gone?.();
|
|
481
|
+
} catch {
|
|
482
|
+
/* the caller's report is best-effort */
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
397
486
|
finish({ url: m[0], user: gate.user, password: gate.password, stop });
|
|
398
487
|
};
|
|
399
488
|
|
package/bin/lib/work.mjs
CHANGED
|
@@ -492,7 +492,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
492
492
|
/* best-effort */
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
|
-
|
|
495
|
+
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
496
|
+
// broadcast — every daemon on the credential gets it — and the one holding
|
|
497
|
+
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
498
|
+
// real holder was never told to stop and its tunnel outlived every
|
|
499
|
+
// surface. (The server drops mismatched confirms too; this is the copy on
|
|
500
|
+
// the component that can be published ahead of a deploy.) A stop for a
|
|
501
|
+
// tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
|
|
502
|
+
// row reads as over once it goes stale.
|
|
503
|
+
if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
504
|
};
|
|
497
505
|
|
|
498
506
|
const processPreviewJobs = (jobs) => {
|
|
@@ -552,6 +560,27 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
552
560
|
livePreviews.delete(sessionId);
|
|
553
561
|
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
562
|
},
|
|
563
|
+
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
564
|
+
// port (5173…) rebound by any other process on the box would keep
|
|
565
|
+
// a bare TCP probe green, and the share's URL+password would serve
|
|
566
|
+
// a worktree nobody consented to publish.
|
|
567
|
+
stillServing: async () => listenersIn(wt).some((l) => l.port === port),
|
|
568
|
+
// The gate closed itself after repeated failed passwords. Stored,
|
|
569
|
+
// so the incident is visible — and the entry is dropped so the
|
|
570
|
+
// owner can re-share the port without restarting the daemon.
|
|
571
|
+
onAbuse: () => {
|
|
572
|
+
livePreviews.delete(sessionId);
|
|
573
|
+
void postPreview({ sessionId, ended: true, endedReason: 'abuse' });
|
|
574
|
+
},
|
|
575
|
+
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
576
|
+
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
577
|
+
onTunnelGone: () => {
|
|
578
|
+
livePreviews.delete(sessionId);
|
|
579
|
+
void postPreview({
|
|
580
|
+
sessionId,
|
|
581
|
+
error: 'the tunnel dropped — share it again to reopen.',
|
|
582
|
+
});
|
|
583
|
+
},
|
|
555
584
|
});
|
|
556
585
|
if (t.error) {
|
|
557
586
|
await postPreview({ sessionId, error: t.error });
|
|
@@ -1195,6 +1224,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1195
1224
|
// worktree would pull the directory out from under a running turn. Absence
|
|
1196
1225
|
// means "the tab closed"; this is the one other thing it can mean.
|
|
1197
1226
|
const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
|
|
1227
|
+
// A peer-held session's CACHED work token is a claim-bypass: the mint is
|
|
1228
|
+
// the one place the session lease 409s a non-holder, and a token younger
|
|
1229
|
+
// than ~23h skips the mint entirely — so a daemon that lost a lease would
|
|
1230
|
+
// run the next turn anyway, editing the worktree while every MCP call
|
|
1231
|
+
// 401s (the peer's mint rotated the secret). Dropping the cache forces
|
|
1232
|
+
// the next turn through the mint, where the 409 stands it down.
|
|
1233
|
+
for (const id of peers) workTokens.delete(id);
|
|
1198
1234
|
const dir = join(baseDir, 'sessions');
|
|
1199
1235
|
if (!existsSync(dir)) return;
|
|
1200
1236
|
let ids;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.54.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.54.2",
|
|
4
|
+
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|