flowviant 0.54.0 → 0.54.1

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 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/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
- note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
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
  }
@@ -1240,6 +1290,36 @@ export async function runFleetDaemon() {
1240
1290
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
1241
1291
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
1242
1292
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
1293
+ // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
1294
+ // this sits ABOVE the version signal rather than inside it. Both read the
1295
+ // same `roster.daemon` object, but `handleVersionSignal` can re-exec this
1296
+ // process into a newer build — so checked second, a machine somebody just
1297
+ // told to stop would come back up wearing a different version instead of
1298
+ // going away.
1299
+ const stopSignal = shouldStop(roster.daemon);
1300
+ if (stopSignal) {
1301
+ warn(
1302
+ stopSignal.reason
1303
+ ? `stopped by Flowviant — ${stopSignal.reason}`
1304
+ : 'stopped by Flowviant — no reason given.'
1305
+ );
1306
+ note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1307
+ // teardown() is NOT optional on this path. Detached preview tunnels
1308
+ // survive this process BY DESIGN, so exiting without it strands a public
1309
+ // hostname pointed into a worktree until somebody reboots the box — which
1310
+ // is precisely the state a remote stop is usually being used to end. It
1311
+ // also kills the session CLIs and the wiki Claude, which would otherwise
1312
+ // keep editing worktrees and burning quota for a machine nobody is
1313
+ // watching any more.
1314
+ teardown();
1315
+ // EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
1316
+ // a failure. Under `Restart=on-failure` a nonzero code has systemd
1317
+ // relaunch the daemon immediately, fighting the very command that stopped
1318
+ // it; exit 0 reads as "the job is done" and leaves it down. The server's
1319
+ // honor window is what makes the other half work — a deliberate relaunch
1320
+ // minutes later comes up clean instead of stopping itself forever.
1321
+ process.exit(0);
1322
+ }
1243
1323
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
1244
1324
  // no workers are spawned yet). If it self-updates it re-execs into the new
1245
1325
  // version and this process becomes a proxy — stop the loop.
@@ -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
- export function daemonInSameRepo(repoRoot, ownPath) {
179
- const dir = join(homedir(), '.flowviant');
180
- let files;
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
- files = readdirSync(dir).filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f));
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 null;
195
+ return [];
185
196
  }
186
- for (const f of files) {
187
- const path = join(dir, f);
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,11 +389,30 @@ 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` predates this and is never signalled.
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
- if (!want) return false;
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);
215
416
  try {
216
417
  if (platform() === 'linux') {
217
418
  return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
@@ -222,7 +423,11 @@ function stillTheHolder(holder) {
222
423
  timeout: 3000,
223
424
  }).includes(want);
224
425
  } catch {
225
- return false; // gone, or unreadable not something we signal
426
+ // NOT `false`. takeOverFrom already returned early if the pid were gone, so
427
+ // reaching here means the process is alive and we could not READ it —
428
+ // hidepid=2, a pid namespace, a stripped image with no `ps`. Saying `false`
429
+ // here is what made the refusal claim the pid belonged to somebody else.
430
+ return null;
226
431
  }
227
432
  }
228
433
 
@@ -242,7 +447,16 @@ const sleep = (ms) => {
242
447
  const TAKEOVER_GRACE_MS = 20_000;
243
448
 
244
449
  /**
245
- * Ask the holder to stand down, then take its place.
450
+ * THE STAND-DOWN, and it is deliberately ONE copy of this.
451
+ *
452
+ * Two callers perform this identical ritual for different reasons — a takeover
453
+ * (a second run in the same repo replacing the daemon serving it) and
454
+ * `flowviant stop` (a person who does not know what is running clearing the
455
+ * box). What they share is a SIGTERM followed by a SIGKILL, and two hand-copies
456
+ * of a SIGKILL are two places to get the grace period, the zombie trap or the
457
+ * mid-update handover subtly wrong. The IDENTITY gate is NOT in here: this
458
+ * function signals whatever it is handed, so every caller must have proved what
459
+ * the pid is before it calls (see stillTheHolder, and read its tri-state note).
246
460
  *
247
461
  * SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
248
462
  * teardown — it kills the CLI children it spawned and stops its preview
@@ -259,18 +473,24 @@ const TAKEOVER_GRACE_MS = 20_000;
259
473
  * because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
260
474
  * this same lock through the ppid branch. Treating that as free steals a live
261
475
  * daemon's lock and leaves it running unguarded — measured doing exactly that.
476
+ *
477
+ * Returns null when the holder is stopped and its lock file is cleared, or
478
+ * `{ failed }` with a sentence the caller can print as-is.
262
479
  */
263
- function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
264
- if (!holder?.pid || !alive(holder.pid)) return null; // already gone
265
- if (!stillTheHolder(holder)) {
266
- return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
267
- }
268
- if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
269
- return {
270
- 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)`,
271
- };
272
- }
273
-
480
+ function standDown(holder, path, log) {
481
+ // NEVER SIGNAL OURSELVES, and this is not a theoretical guard — it was
482
+ // reproduced end to end. A 0.54.0+ lock records `entry` = the daemon's
483
+ // argv[1], i.e. `…/bin/cli.mjs`. Run `flowviant stop` and OUR cmdline is
484
+ // `node …/bin/cli.mjs stop`, which CONTAINS that string. So if a stale lock's
485
+ // pid has been recycled to us ordinary on a host with pid_max 32768, and
486
+ // stale locks are deliberately left on disk — stillTheHolder answers `true`
487
+ // ABOUT THE SWEEPER and this function SIGTERMs the process running it. The
488
+ // command then dies mid-line, every later lock goes unexamined, and the real
489
+ // daemons it was asked to stop keep running. It is the signal-the-wrong-
490
+ // process bug arriving through a POSITIVE identification, which is why the
491
+ // identity check cannot catch it and the guard belongs here, at the one place
492
+ // that signals, rather than in each caller.
493
+ if (holder.pid === process.pid) return { failed: 'refusing to signal this very process' };
274
494
  log?.(`asking daemon pid ${holder.pid} to stand down…`);
275
495
  try {
276
496
  process.kill(holder.pid, 'SIGTERM');
@@ -316,6 +536,44 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
316
536
  } catch {
317
537
  return { failed: 'could not clear the lock file' };
318
538
  }
539
+ return null;
540
+ }
541
+
542
+ /**
543
+ * Ask the holder to stand down, then take its place.
544
+ *
545
+ * WHAT THIS FUNCTION IS, now that the choreography lives in standDown above: the
546
+ * IDENTITY GATE. Nothing below signals anything until the pid has been proved to
547
+ * be the process that wrote this lock — a lock records a PID, pids are recycled,
548
+ * and a crashed daemon's number goes to whatever forks next. `stillTheHolder` is
549
+ * tri-state and both of its refusing values are reported, separately, because
550
+ * "it is someone else" and "we could not look" are different sentences and
551
+ * collapsing them once had the daemon telling people a live holder was stale.
552
+ */
553
+ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
554
+ if (!holder?.pid || !alive(holder.pid)) return null; // already gone
555
+ const identified = stillTheHolder(holder);
556
+ if (identified === null) {
557
+ // We know nothing about this pid, and said so. The remedy is a human
558
+ // stopping it, NOT running a second daemon alongside it.
559
+ return {
560
+ failed:
561
+ `cannot confirm what pid ${holder.pid} is on this host — no readable /proc or ps — ` +
562
+ `so it will not be signalled. Stop that process yourself and start this one again.`,
563
+ unidentified: true,
564
+ };
565
+ }
566
+ if (identified === false) {
567
+ return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
568
+ }
569
+ if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
570
+ return {
571
+ 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)`,
572
+ };
573
+ }
574
+
575
+ const bad = standDown(holder, path, log);
576
+ if (bad) return bad;
319
577
  log?.(`daemon pid ${holder.pid} stopped — taking over.`);
320
578
  return null;
321
579
  }
@@ -351,7 +609,8 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
351
609
  if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
352
610
  log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
353
611
  const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
354
- if (bad) return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed };
612
+ if (bad)
613
+ return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
355
614
  }
356
615
 
357
616
  // Two passes at most: one to clear a stale holder, one to take the lock. A
@@ -394,7 +653,8 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
394
653
  const wanted = force || (here && !noTakeover);
395
654
  if (wanted) {
396
655
  const bad = takeOverFrom(holder, path, log, { allowDowngrade });
397
- if (bad) return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here };
656
+ if (bad)
657
+ return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
398
658
  continue; // the file is gone — the next pass takes it
399
659
  }
400
660
  return { ok: false, holder, sameRepo: here };
@@ -429,3 +689,138 @@ function makeRelease(path) {
429
689
  process.on('exit', release);
430
690
  return release;
431
691
  }
692
+
693
+ /**
694
+ * STOP EVERY FLOWVIANT DAEMON ON THIS MACHINE — `flowviant stop`.
695
+ *
696
+ * WHY IT IS "EVERY" AND NOT "THIS REPO'S". The friction this exists to remove is
697
+ * not knowing what is running. Somebody who could NAME the daemon they meant
698
+ * would not need this command — they would already have the pid. What actually
699
+ * happens is that they run `flowviant`, hit a refusal naming a pid and a
700
+ * directory they do not recognise, and go hunting through `ps`. So this takes no
701
+ * argument, asks no question, and sweeps every credential's lock file rather
702
+ * than the one this checkout happens to key to: a stop command with a scope is a
703
+ * stop command you have to be sure about before you can use it.
704
+ *
705
+ * IT IDENTIFIES BEFORE IT SIGNALS, exactly as takeover does and for the same
706
+ * reason — a lock records a PID, pids are recycled, and there is no such thing
707
+ * as a harmless guess about which process to kill. The tri-state from
708
+ * stillTheHolder is reported as three DIFFERENT sentences and never collapsed:
709
+ *
710
+ * true -> stopped, through the same standDown the takeover path uses.
711
+ * false -> measured, and that pid is somebody else now. Nothing is signalled
712
+ * and it is NOT a failure: the daemon that wrote the lock is gone,
713
+ * which is the answer the asker wanted.
714
+ * null -> COULD NOT CONFIRM — either the lock carries no witness to match
715
+ * against, or the host hides the process (hidepid=2, a pid
716
+ * namespace, an image with no `ps`); the line below names BOTH,
717
+ * since we did not measure which. Said out loud WITH THE PID,
718
+ * because we have just declined to touch a live process and the
719
+ * remedy is a human running `kill`.
720
+ * That one counts as a failure — something is alive and we did not
721
+ * stop it — which is the ONLY thing that makes this command exit
722
+ * non-zero.
723
+ *
724
+ * A STALE LOCK IS LEFT ON DISK. Unlinking one looks tidy and races a daemon that
725
+ * is starting RIGHT NOW: acquire clears a dead holder's file and then re-creates
726
+ * it with `wx`, so a sweep landing between those two steps deletes a LIVE
727
+ * daemon's lock and leaves it running unguarded — the one condition this whole
728
+ * module exists to prevent. Clearing stale files is acquire's job, it already
729
+ * does it, and it does it without the race.
730
+ *
731
+ * FINDING NOTHING IS THE FEATURE, not an error: "no flowviant daemon is running
732
+ * on this machine." is the sentence the person who did not know came for, and it
733
+ * exits 0. Reported through `log` line by line as the sweep goes — a person
734
+ * watching a SIGTERM wants to see which pid it went to while it is happening,
735
+ * not in a summary afterwards.
736
+ *
737
+ * Returns `{ stopped, unconfirmed, failed }`; the caller turns `failed` into the
738
+ * exit code.
739
+ */
740
+ export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
741
+ let stopped = 0;
742
+ let unconfirmed = 0;
743
+ let failed = 0;
744
+ let running = 0; // locks naming a process we believe is, or might be, a daemon
745
+
746
+ for (const path of lockFiles()) {
747
+ const holder = readHolder(path);
748
+ if (!holder) continue; // absent, truncated, half-written — no claim to answer
749
+ const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
750
+ const what = holder.version ? ` ${holder.version}` : '';
751
+
752
+ if (!alive(holder.pid)) {
753
+ log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
754
+ continue;
755
+ }
756
+ // Us. standDown refuses this too, but reaching it would print a stand-down
757
+ // line and then a failure for the one pid we are certain is not a daemon.
758
+ if (holder.pid === process.pid) continue;
759
+
760
+ const identified = stillTheHolder(holder);
761
+ if (identified === false) {
762
+ // A live pid, but measured NOT to be the process that wrote this lock. The
763
+ // daemon is gone; the number was handed to something else. Not counted as
764
+ // running, and deliberately not counted as a failure either.
765
+ log(`pid ${holder.pid} is no longer the daemon that took this lock — nothing signalled.`);
766
+ continue;
767
+ }
768
+ running++;
769
+ if (identified === null) {
770
+ unconfirmed++;
771
+ failed++;
772
+ // NAMES BOTH CAUSES, because null has two and we did not measure which:
773
+ // the lock may carry no witness to match against (neither `entry` nor
774
+ // `startedAt` — a hand-edited or half-written file), or this host may hide
775
+ // the process from us (hidepid=2, a pid namespace, an image with no `ps`).
776
+ // "no readable /proc" alone is what takeover says, and said HERE it would
777
+ // assert a diagnosis nobody established — over a live process we are about
778
+ // to tell someone to kill.
779
+ // `kill` is not a command on Windows, where platform() is 'win32' and
780
+ // processStartedAt has no implementation at all — so EVERY lock lands in
781
+ // this branch and the whole command is a no-op that exits 1. Say that
782
+ // once, in the platform's own vocabulary, rather than handing someone a
783
+ // remedy their shell does not have.
784
+ const byHand =
785
+ platform() === 'win32'
786
+ ? `taskkill /PID ${holder.pid} /F`
787
+ : `kill ${holder.pid}`;
788
+ log(
789
+ `could not confirm that pid ${holder.pid} is still a flowviant daemon — its lock carries ` +
790
+ `nothing to match it against, or this host hides the process from us` +
791
+ `${platform() === 'win32' ? ' (identifying a process is not implemented on Windows)' : ''}` +
792
+ ` — so it was NOT signalled. Stop it by hand: ${byHand}`
793
+ );
794
+ continue;
795
+ }
796
+
797
+ const bad = standDown(holder, path, log);
798
+ if (bad) {
799
+ failed++;
800
+ log(`could not stop daemon pid ${holder.pid}${where}: ${bad.failed}`);
801
+ continue;
802
+ }
803
+ stopped++;
804
+ log(`stopped daemon${what} pid ${holder.pid}${where}.`);
805
+ }
806
+
807
+ // WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
808
+ // sentence — "no flowviant daemon is running on this machine" — was asserted
809
+ // from lock files alone, and there are several ways to run a daemon that
810
+ // holds no readable lock: FLOWVIANT_ALLOW_MULTI=1 returns before the
811
+ // filesystem is touched (and fleet.mjs PRINTS that flag as the way out of an
812
+ // "already running" refusal, so a stuck user is steered straight onto it), an
813
+ // unwritable ~/.flowviant runs `unguarded`, and every daemon before 0.51.2
814
+ // predates the lock entirely. Telling somebody "nothing is running" while
815
+ // something is, is this product's cardinal sin: it turns ignorance into a
816
+ // state. So the claim is scoped to what was looked at, and the ways past it
817
+ // are named rather than left for them to discover.
818
+ if (!running) {
819
+ log('no flowviant daemon holds a lock on this machine.');
820
+ log(
821
+ '(a daemon started with FLOWVIANT_ALLOW_MULTI=1, or one older than 0.51.2, holds no lock — ' +
822
+ 'this cannot see those. `pgrep -af flowviant` will.)'
823
+ );
824
+ }
825
+ return { stopped, unconfirmed, failed };
826
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {