flowviant 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lib/fleet.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  USER_AGENT,
24
24
  MCP_URL,
25
25
  SAFE,
26
+ DAEMON_INSTANCE,
26
27
  POLL_SECONDS,
27
28
  MAX_CONCURRENT,
28
29
  IDLE_SECONDS,
@@ -30,7 +31,6 @@ import {
30
31
  REFRESH_BEFORE_SECONDS,
31
32
  LIVE,
32
33
  AUTO_UPDATE,
33
- ALLOW_PATCHES,
34
34
  } from './config.mjs';
35
35
  import { handleVersionSignal } from './update.mjs';
36
36
  import {
@@ -77,7 +77,7 @@ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtime
77
77
  import { createWorkManager } from './work.mjs';
78
78
  import { scanLocalSessions } from './localSessions.mjs';
79
79
 
80
- async function fetchRoster(haveIds) {
80
+ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds = []) {
81
81
  const url = new URL(FLEET_URL);
82
82
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
83
83
  // What this machine will run at once. The server grows lanes to meet waiting
@@ -98,6 +98,20 @@ async function fetchRoster(haveIds) {
98
98
  // so a team can see whether the shared box runs wide open, and enforces
99
99
  // nothing (membership is the consent boundary). Older servers ignore it.
100
100
  url.searchParams.set('safe', SAFE ? '1' : '0');
101
+ // WHICH PROCESS, so the server can lease preview work to exactly one of two
102
+ // daemons on one credential. Older servers ignore unknown params.
103
+ url.searchParams.set('di', DAEMON_INSTANCE);
104
+ // Which shares this machine is still serving. It rides the poll rather than
105
+ // taking an endpoint of its own: one beat, no floor, and the stale window is
106
+ // the reconcile interval instead of minutes — which matters, because a share
107
+ // the server still calls live is a 530 on somebody's phone. Always set, even
108
+ // empty: '' means "serving none", absent would mean "an older daemon".
109
+ url.searchParams.set('pv', livePreviewSessionIds.join(','));
110
+ // The sessions this daemon holds a worktree for. Its LEASE on each renews
111
+ // here — one beat, no extra endpoint, and the server can tell "this daemon is
112
+ // still serving that tab" from "it went away" within a reconcile interval
113
+ // instead of minutes. Always set, even empty: '' means "holding none".
114
+ url.searchParams.set('ws', heldSessionIds.join(','));
101
115
  // WHICH CLIs this machine actually has, so the app can stop guessing.
102
116
  //
103
117
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -261,20 +275,13 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
261
275
 
262
276
  export async function runFleetDaemon() {
263
277
  console.log('');
264
- console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`fleet daemon · v${VERSION}`)}`);
278
+ console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
265
279
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
266
280
  const repoRoot = repoRootOrDie();
267
281
  const baseRef = detectBaseRef(repoRoot);
268
282
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
269
283
  info(`repo · ${repoRoot}`);
270
284
  info(`base · ${baseRef}`);
271
- // Stated out loud because it is the one setting that lets something else write
272
- // into the checkout you are sitting in.
273
- info(
274
- ALLOW_PATCHES
275
- ? 'patches· accepted — small changes land in your checkout for Keep/Revert (--no-patches to refuse)'
276
- : 'patches· refused — everything arrives as a branch + PR'
277
- );
278
285
  info(`server · ${FLEET_URL}`);
279
286
  console.log('');
280
287
 
@@ -285,18 +292,42 @@ export async function runFleetDaemon() {
285
292
  // the SAME project served twice, and the worst version of this: their session
286
293
  // worktrees are in different directories, so the per-turn lock cannot even see
287
294
  // across them. See instance.mjs for why that lock is not enough on its own.
288
- const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot);
295
+ // Same repo -> this run replaces whatever was serving it. Different repo ->
296
+ // refused, and nothing is signalled. See instance.mjs's header for the rule.
297
+ const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
298
+ takeover:
299
+ process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
300
+ noTakeover:
301
+ process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
302
+ allowDowngrade: process.argv.includes('--takeover-downgrade'),
303
+ log: (m) => info(m),
304
+ });
289
305
  if (!instance.ok) {
290
306
  const h = instance.holder;
291
307
  console.log('');
292
- fail('a flowviant daemon is already running for this credential.');
308
+ // Two different refusals, because they are two different mistakes and the
309
+ // fix is not the same. Same CREDENTIAL: one project is being served twice.
310
+ // Same REPO under another credential: two daemons in one working tree,
311
+ // which the credential-keyed lock cannot see on its own.
312
+ if (instance.takeoverFailed) {
313
+ fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
314
+ } else if (instance.sameRepo) {
315
+ fail('a flowviant daemon is already running in this repo.');
316
+ } else {
317
+ fail('a flowviant daemon is already running for this credential.');
318
+ }
293
319
  if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
294
320
  // The two-checkouts case is the one nobody spots on their own: both tabs
295
321
  // look healthy, and the damage is doubled cards and doubled edits in a repo
296
322
  // you are not looking at. Name the other repo when it is a different one.
297
- if (h?.repoRoot && h.repoRoot !== repoRoot)
323
+ if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
298
324
  warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
299
- note('stop the other one first, or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
325
+ // Not offered lightly: that daemon is serving other work, and this
326
+ // command was run somewhere else. Replacing it is a decision, not a
327
+ // restart, so it takes a word.
328
+ note('run with --takeover to stop it and serve this repo instead.');
329
+ }
330
+ note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
300
331
  console.log('');
301
332
  process.exit(1);
302
333
  }
@@ -374,6 +405,14 @@ export async function runFleetDaemon() {
374
405
  const nextByAgent = new Map();
375
406
  let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
376
407
  let mcpUrl = MCP_URL;
408
+ // DEAD, and kept only because unpicking it is a rewire rather than a
409
+ // deletion: nothing calls `workers.set` anywhere in this tree. It held
410
+ // DISPATCH lanes, and the server has sent `agents: []` permanently since
411
+ // 2026-08-19, so every loop below iterates nothing. Two `stopPreview` calls
412
+ // hung off it until 2026-08-21 and read as live preview wiring; they were
413
+ // deleted, not rewired. When the Workbench preview lands, its teardown is
414
+ // keyed on sessionId and belongs beside retireWorkSessions in work.mjs —
415
+ // NOT here.
377
416
  const workers = new Map(); // agentId -> { state, promise, wt, label }
378
417
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
379
418
  let stream = null; // push channel handle (set once the loop is set up)
@@ -413,15 +452,10 @@ export async function runFleetDaemon() {
413
452
  } catch {
414
453
  /* best-effort */
415
454
  }
416
- // Stop the detached preview (dev server + cloudflared tunnel) — it's its
417
- // own process group and survives our exit, otherwise leaking a port-bound
418
- // server + a live tunnel serving a stale branch until reboot.
419
- try {
420
- w.state.stopPreview?.();
421
- } catch {
422
- /* best-effort */
423
- }
424
455
  }
456
+ // Detached tunnels survive our exit by design, so leaving them would strand
457
+ // a public hostname until the box rebooted.
458
+ shutdownPreviews();
425
459
  };
426
460
  process.on('SIGINT', () => {
427
461
  console.log('');
@@ -593,6 +627,11 @@ export async function runFleetDaemon() {
593
627
  processWorkTurns,
594
628
  processShipJobs,
595
629
  processDiffJobs,
630
+ heldSessionIds,
631
+ processPreviewJobs,
632
+ livePreviewIds,
633
+ retirePreviews,
634
+ shutdownPreviews,
596
635
  retireWorkSessions,
597
636
  reportWorktrees,
598
637
  shutdownWork,
@@ -856,20 +895,42 @@ export async function runFleetDaemon() {
856
895
 
857
896
  // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
858
897
  // PR can't blow up the prompt. prUrl was already validated before the merge.
859
- // Returns null on a gh FAILURE (network/auth) distinct from a PR that
860
- // genuinely changed nothing — so the caller can retry instead of silently
861
- // consuming the durable job with no re-ground run.
862
- const changedFilesForPr = (prUrl) => {
863
- try {
864
- const out = execFileSync('gh', ['pr', 'view', prUrl, '--json', 'files'], {
865
- cwd: repoRoot,
866
- encoding: 'utf8',
867
- stdio: ['ignore', 'pipe', 'pipe'],
868
- });
869
- return (JSON.parse(out).files ?? []).map((f) => f.path).filter(Boolean).slice(0, 60);
870
- } catch {
871
- return null;
898
+ // WHICH FILES A SHIP CHANGED, read from the commits it landed.
899
+ //
900
+ // This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
901
+ // has been null by construction since dispatch was deleted on 2026-08-19 —
902
+ // the server writes null and says so in a comment. Node threw on the null
903
+ // argument, the catch below read it as "gh failed", and the re-ground retried
904
+ // three times and gave up. Every post-ship re-ground for three months did
905
+ // that silently, while the spec said ship re-grounds the wiki.
906
+ //
907
+ // Returns null when it learned NOTHING (no shas, or none of them resolvable),
908
+ // which the caller still treats as retryable — distinct from a ship that
909
+ // genuinely changed no files.
910
+ const changedFilesForShas = (shas) => {
911
+ if (!Array.isArray(shas) || shas.length === 0) return null;
912
+ const files = new Set();
913
+ for (const sha of shas.slice(0, 50)) {
914
+ if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
915
+ try {
916
+ const out = execFileSync(
917
+ 'git',
918
+ ['show', '--name-only', '--pretty=format:', String(sha)],
919
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
920
+ );
921
+ for (const line of out.split('\n')) {
922
+ const f = line.trim();
923
+ if (f) files.add(f);
924
+ if (files.size >= 60) break;
925
+ }
926
+ } catch {
927
+ // One unreachable commit is not a failed re-ground — the ship merged
928
+ // to main and the rest of the shas still name real files. Only an
929
+ // EMPTY result is treated as "we learned nothing".
930
+ }
931
+ if (files.size >= 60) break;
872
932
  }
933
+ return files.size ? [...files] : null;
873
934
  };
874
935
  const regroundAttempts = new Map(); // intentId -> gh-failure count
875
936
 
@@ -1025,7 +1086,7 @@ export async function runFleetDaemon() {
1025
1086
  warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
1026
1087
  await runSync(complete);
1027
1088
  } else {
1028
- const files = changedFilesForPr(task.prUrl);
1089
+ const files = changedFilesForShas(task.shas);
1029
1090
  if (files === null) {
1030
1091
  // gh failed (network/auth) — retry via the durable job a couple
1031
1092
  // of times before consuming it, so a transient outage doesn't
@@ -1033,11 +1094,11 @@ export async function runFleetDaemon() {
1033
1094
  const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
1034
1095
  regroundAttempts.set(task.intentId, n);
1035
1096
  if (n < 3) {
1036
- warn(`wiki re-ground for "${task.title}": gh failed — will retry (${n}/3)`);
1097
+ warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
1037
1098
  groundedIntents.delete(task.intentId); // let the roster re-offer it
1038
1099
  continue;
1039
1100
  }
1040
- warn(`wiki re-ground for "${task.title}": gh failed ${n} times — giving up (heals on the next full sweep)`);
1101
+ warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
1041
1102
  } else if (files.length === 0) {
1042
1103
  note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
1043
1104
  } else {
@@ -1153,7 +1214,7 @@ export async function runFleetDaemon() {
1153
1214
  for (;;) {
1154
1215
  let roster;
1155
1216
  try {
1156
- roster = await fetchRoster(buildHave());
1217
+ roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1157
1218
  } catch (e) {
1158
1219
  if (e.auth) {
1159
1220
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1215,11 +1276,29 @@ export async function runFleetDaemon() {
1215
1276
  // AFTER the work/ship intake: retirement is the server saying which
1216
1277
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1217
1278
  // by the intake this same tick.
1218
- retireWorkSessions(roster.activeWorkSessions);
1279
+ // BEFORE retirement, and the order is load-bearing: `git worktree remove`
1280
+ // under a running dev server leaves it serving bytes from open file handles
1281
+ // in a directory that no longer exists — a human is shown the wrong thing
1282
+ // and nothing errors anywhere.
1283
+ retirePreviews(roster.activeWorkSessions);
1284
+ // A session another daemon on this credential is serving is NOT a closed
1285
+ // tab. Without this the daemon that lost the lease removes the worktree the
1286
+ // winner is working in — absence would mean "somebody else won" instead of
1287
+ // "the tab closed".
1288
+ retireWorkSessions(
1289
+ Array.isArray(roster.activeWorkSessions)
1290
+ ? roster.activeWorkSessions
1291
+ : roster.activeWorkSessions,
1292
+ roster.sessionsHeldElsewhere
1293
+ );
1219
1294
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1220
1295
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1221
1296
  // tab's branch and a shipped commit on main alike.
1222
1297
  processDiffJobs(roster.diffJobs);
1298
+ // Shares to open or tear down. CLAIMED before acted on — two daemons on one
1299
+ // credential are both handed this array, and both opening a tunnel strands
1300
+ // a public hostname nobody can settle.
1301
+ processPreviewJobs(roster.previewJobs);
1223
1302
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1224
1303
  // Throttled inside, never awaited — a `git status` the human cannot run
1225
1304
  // themselves from a browser, relayed. After retirement so a directory that
@@ -1236,24 +1315,23 @@ export async function runFleetDaemon() {
1236
1315
  const sig = [...rosterIds].sort().join(',');
1237
1316
  if (sig !== rosterSig) {
1238
1317
  rosterSig = sig;
1239
- if (rosterIds.size === 0) {
1240
- // `agents` is permanently [] the lanes it counted died with dispatch
1241
- // and the array survives only as wire compat. So this branch is the one
1242
- // that always runs, and it used to point at the Cockpit, a surface
1243
- // deleted 2026-08-04 that now redirects to the Board. Say what is
1244
- // actually true instead: the machine is up, and work starts in a tab.
1245
- info('Machine online. Open a tab in Flowviant → Workbench to start working.');
1246
- } else {
1247
- note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
1248
- }
1318
+ // `agents` is permanently [] — the lanes it counted died with dispatch
1319
+ // and the array survives only as wire compat, so this runs once, on the
1320
+ // first poll. It used to point at the Cockpit, a surface deleted
1321
+ // 2026-08-04 that now redirects to the Board. Say what is actually true
1322
+ // instead: the machine is up, and work starts in a tab.
1323
+ info('Machine online. Open a tab in Flowviant Workbench to start working.');
1249
1324
  }
1250
- // Heartbeat so a quiet/empty daemon visibly stays alive.
1251
- if (rosterIds.size === 0 && Date.now() - idleBeatAt > 60_000) {
1325
+ // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
1326
+ // `rosterIds` is built from `roster.agents`, which the server sends
1327
+ // permanently empty, so gating on it printed "waiting" once a minute even
1328
+ // while a tab's turn was running. `workBusy()` is the honest question: are
1329
+ // there session turns, ships or unsettled reports in flight?
1330
+ if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
1252
1331
  idleBeatAt = Date.now();
1253
- info('idlewaiting for agents…');
1332
+ info('machine online nothing running right now.');
1254
1333
  }
1255
1334
 
1256
-
1257
1335
  // Living-wiki work (runs under its own minted wiki token — no agent
1258
1336
  // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
1259
1337
  // deliveries whose re-ground never ran (e.g. we restarted between merge and
@@ -1269,13 +1347,18 @@ export async function runFleetDaemon() {
1269
1347
 
1270
1348
  // Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
1271
1349
  // dictates (self-guarded — one operation at a time, errors retry next
1272
- // poll). A fresh bundle rematerializes every AGENT worktree; the wiki
1273
- // worktree NEVER gets env (the cartographer doesn't need secrets).
1350
+ // poll). A fresh bundle rematerializes every SESSION worktree this daemon
1351
+ // holds read off the sessions directory, the same fact retirement acts
1352
+ // on; the wiki worktree NEVER gets env (the cartographer doesn't need
1353
+ // secrets). This used to iterate the dispatch-era `workers` map, which
1354
+ // nothing has ever `.set()`, so a rotation reached no worktree at all.
1355
+ // Safe mid-turn by construction: materializeInto refuses to write anything
1356
+ // git does not ignore, so it cannot dirty a tree and block a ship.
1274
1357
  void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
1275
1358
  if (!changed) return;
1276
- for (const [, w] of workers) {
1359
+ for (const id of heldSessionIds()) {
1277
1360
  try {
1278
- materializeInto(w.wt);
1361
+ materializeInto(join(baseDir, 'sessions', id));
1279
1362
  } catch {
1280
1363
  /* best-effort */
1281
1364
  }
@@ -1323,11 +1406,6 @@ export async function runFleetDaemon() {
1323
1406
  } catch {
1324
1407
  /* best-effort */
1325
1408
  }
1326
- try {
1327
- w.state.stopPreview?.();
1328
- } catch {
1329
- /* best-effort */
1330
- }
1331
1409
  // Only poll mode's per-lane tree dies with the lane. A live lane owns no
1332
1410
  // checkout: the task it was building has its own, which must SURVIVE —
1333
1411
  // removing a lane requeues its task, and the next lane to pick that task
package/bin/lib/git.mjs CHANGED
@@ -35,7 +35,7 @@ export function repoRootOrDie() {
35
35
  try {
36
36
  return git(['rev-parse', '--show-toplevel'], process.cwd());
37
37
  } catch {
38
- console.error('error: fleet mode must run inside a git repo.');
38
+ console.error('error: the flowviant daemon must run inside a git repo.');
39
39
  process.exit(1);
40
40
  }
41
41
  }