flowviant 0.54.1 → 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.
@@ -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
- const claimed = await post('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => null);
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
@@ -782,7 +782,7 @@ export async function runFleetDaemon() {
782
782
  // Direct enqueue = immediacy; the server's durable regroundJobs list
783
783
  // (created by merge-done above, cleared by our reground-done report)
784
784
  // is the restart-safe backstop — dedup'd here by groundedIntents.
785
- enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
785
+ enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
786
786
  } else if (failedReason) {
787
787
  // Report into the thread (server narrates + re-arms the merge
788
788
  // button + notifies) — the job disappears from the roster.
@@ -926,7 +926,7 @@ export async function runFleetDaemon() {
926
926
  wikiQueue.push({ type: 'sweep' });
927
927
  void drainWiki();
928
928
  };
929
- const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
929
+ const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
930
930
  if (!intentId || groundedIntents.has(intentId)) return;
931
931
  groundedIntents.add(intentId);
932
932
  wikiQueue.push({
@@ -939,6 +939,13 @@ export async function runFleetDaemon() {
939
939
  // frontmatter file list has drifted, or that document a concept rather
940
940
  // than a directory.
941
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 : [],
942
949
  });
943
950
  void drainWiki();
944
951
  };
@@ -1185,6 +1192,13 @@ export async function runFleetDaemon() {
1185
1192
  // crash BEFORE this line leaves the job listed for a retry.
1186
1193
  regroundAttempts.delete(task.intentId);
1187
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);
1188
1202
  }
1189
1203
  } catch (e) {
1190
1204
  warn(`wiki ${task.type} failed: ${e.message}`);
@@ -1269,7 +1283,13 @@ export async function runFleetDaemon() {
1269
1283
  if (e.auth) {
1270
1284
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
1271
1285
  teardown();
1272
- process.exit(1);
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);
1273
1293
  }
1274
1294
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
1275
1295
  await sleep(RECONCILE_SECONDS);
@@ -1304,6 +1324,17 @@ export async function runFleetDaemon() {
1304
1324
  : 'stopped by Flowviant — no reason given.'
1305
1325
  );
1306
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
+ }
1307
1338
  // teardown() is NOT optional on this path. Detached preview tunnels
1308
1339
  // survive this process BY DESIGN, so exiting without it strands a public
1309
1340
  // hostname pointed into a worktree until somebody reboots the box — which
@@ -1421,7 +1452,7 @@ export async function runFleetDaemon() {
1421
1452
  for (const j of roster.regroundJobs ?? []) {
1422
1453
  const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
1423
1454
  if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
1424
- enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
1455
+ enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
1425
1456
  }
1426
1457
  void drainWiki();
1427
1458
 
@@ -413,15 +413,16 @@ function stillTheHolder(holder) {
413
413
  // `process.argv[1] || ''` — and matching on '' would match every process
414
414
  // alive, so it takes the same road as a missing one.
415
415
  if (!want) return startedAroundLockWrite(holder);
416
+ let cmdline;
416
417
  try {
417
- if (platform() === 'linux') {
418
- return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
419
- }
420
- return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
421
- encoding: 'utf8',
422
- stdio: ['ignore', 'pipe', 'ignore'],
423
- timeout: 3000,
424
- }).includes(want);
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
+ });
425
426
  } catch {
426
427
  // NOT `false`. takeOverFrom already returned early if the pid were gone, so
427
428
  // reaching here means the process is alive and we could not READ it —
@@ -429,6 +430,45 @@ function stillTheHolder(holder) {
429
430
  // here is what made the refusal claim the pid belonged to somebody else.
430
431
  return null;
431
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 });
468
+ } catch {
469
+ /* best-effort; a stale file is cleared by the next acquire */
470
+ }
471
+ return true;
432
472
  }
433
473
 
434
474
  /** Blocking, because this runs before there is an event loop worth yielding to
@@ -530,11 +570,11 @@ function standDown(holder, path, log) {
530
570
  sleep(400);
531
571
  }
532
572
 
533
- // A SIGKILLed daemon never ran its release(), so clear what it left.
534
- try {
535
- rmSync(path, { force: true });
536
- } catch {
537
- return { failed: 'could not clear the lock file' };
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' };
538
578
  }
539
579
  return null;
540
580
  }
@@ -564,7 +604,13 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
564
604
  };
565
605
  }
566
606
  if (identified === false) {
567
- return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
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 };
568
614
  }
569
615
  if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
570
616
  return {
@@ -608,9 +654,19 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
608
654
  // quietly.
609
655
  if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
610
656
  log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
611
- const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
612
- if (bad)
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) {
613
668
  return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
669
+ }
614
670
  }
615
671
 
616
672
  // Two passes at most: one to clear a stale holder, one to take the lock. A
@@ -623,11 +679,20 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
623
679
  if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
624
680
  const holder = readHolder(path);
625
681
  if (!holder || !alive(holder.pid)) {
626
- // A crashed daemon's leftover. Clear it and take it on the next pass.
627
- try {
628
- rmSync(path, { force: true });
629
- } catch {
630
- return { ok: true, release: () => {}, unguarded: true };
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
+ }
631
696
  }
632
697
  continue;
633
698
  }
@@ -653,10 +718,28 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
653
718
  const wanted = force || (here && !noTakeover);
654
719
  if (wanted) {
655
720
  const bad = takeOverFrom(holder, path, log, { allowDowngrade });
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
+ }
656
729
  if (bad)
657
730
  return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
658
731
  continue; // the file is gone — the next pass takes it
659
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
+ }
660
743
  return { ok: false, holder, sameRepo: here };
661
744
  }
662
745
  try {
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
- store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
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
@@ -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
- mutateRegistry((list) => [...list, { pid, sig }]);
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
- for (const { pid, sig } of list) {
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({ port, log, onDead, probeMs = 20_000 }) {
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({ targetPort: port, log, onAbuse: () => stop() });
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
- recordPreviewPid(tunnel.pid, 'cloudflared');
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, not the tunnel. A dead dev server behind a live
382
- // hostname is the failure a viewer cannot diagnose.
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
- if (!(await isListening(port))) {
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
- await postPreview({ sessionId, ended: true, endedReason: reason });
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.1",
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.",
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"