flowviant 0.82.0 → 0.84.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
@@ -24,6 +24,7 @@ import {
24
24
  MCP_URL,
25
25
  SAFE,
26
26
  DAEMON_INSTANCE,
27
+ MACHINE_HOST,
27
28
  POLL_SECONDS,
28
29
  MAX_CONCURRENT,
29
30
  IDLE_SECONDS,
@@ -87,10 +88,18 @@ import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
87
88
  import { repoState } from './repoState.mjs';
88
89
  import { claudeAuthContext } from './claudeAuth.mjs';
89
90
 
91
+ /** Said once per process — see the catch around `envQueryParams` below. */
92
+ let warnedEnvIdentity = false;
93
+
90
94
  async function fetchRoster(
91
95
  haveIds,
92
96
  livePreviewSessionIds = [],
93
- heldSessionIds = []
97
+ heldSessionIds = [],
98
+ /** The churn ADMISSION verdict, taken by the caller from the same `admit`
99
+ * every unattended lane asks — see the `pr` param below for why it is the
100
+ * admission and not the pressure reading alone. Undefined where the caller
101
+ * has no admission to offer, which reads exactly like an older daemon. */
102
+ churnHold = undefined
94
103
  ) {
95
104
  const url = new URL(FLEET_URL);
96
105
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -115,6 +124,15 @@ async function fetchRoster(
115
124
  // WHICH PROCESS, so the server can lease preview work to exactly one of two
116
125
  // daemons on one credential. Older servers ignore unknown params.
117
126
  url.searchParams.set('di', DAEMON_INSTANCE);
127
+ // WHICH BOX, by name, so the app can say "your machine is mac-mini" instead
128
+ // of naming a public key. Display only: arbitration is keyed on `envpub`,
129
+ // which is durable per box, while a hostname is neither unique nor stable.
130
+ // Absent when the host has no readable name — an older daemon looks the same,
131
+ // and both mean "nobody said", which is what the nameless fallback renders.
132
+ if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
133
+ // NOTHING HERE ASKS FOR THE MACHINE. A poll reports what this box IS; moving
134
+ // the project's machine onto it is a gesture a person makes in the app, and
135
+ // the daemon learns the outcome on its next poll like every other holder fact.
118
136
  // Which shares this machine is still serving. It rides the poll rather than
119
137
  // taking an endpoint of its own: one beat, no floor, and the stale window is
120
138
  // the reconcile interval instead of minutes — which matters, because a share
@@ -174,8 +192,75 @@ async function fetchRoster(
174
192
  for (const [k, v] of Object.entries(await envQueryParams())) {
175
193
  if (v != null) url.searchParams.set(k, v);
176
194
  }
177
- } catch {
195
+ } catch (e) {
178
196
  /* env identity is best-effort — the poll must never fail on it */
197
+ /**
198
+ * …BUT IT IS NOT SILENT, because since 2026-09-14 losing it costs
199
+ * something visible. `envpub` is how the server tells two computers apart,
200
+ * so a poll without one is EXEMPT from arbitration — it is served in full,
201
+ * which is right — and nothing stamps `holder_heard_at`, which is the
202
+ * column presence is now read from. The app therefore says the machine is
203
+ * offline while this daemon is sitting here answering turns, and before
204
+ * this line the only evidence anywhere was a keypair file nobody looks at.
205
+ *
206
+ * Once per process: it is the same failure on every poll, and a reason
207
+ * repeated every few seconds is a reason nobody reads.
208
+ */
209
+ if (!warnedEnvIdentity) {
210
+ warnedEnvIdentity = true;
211
+ console.warn(
212
+ `[flowviant] could not read ${'~/.flowviant/env-keypair.json'} (${e?.message ?? e}).\n` +
213
+ ' This machine cannot identify itself, so the app may show it as offline while it works.\n' +
214
+ ' It has NOT been replaced — that file is what the project secrets are sealed to.'
215
+ );
216
+ }
217
+ }
218
+ /**
219
+ * WHY THIS MACHINE IS NOT TAKING NEW WORK, in its own measured words.
220
+ *
221
+ * Three states, and the third is why this is a param and not a header:
222
+ * · a reason — the churn ADMISSION refused; new unattended work is being
223
+ * deferred, and the board can say so AT the agent that is
224
+ * waiting instead of leaving it looking like a slow model.
225
+ * · `-` — asked, and nothing is holding anything back. A POSITIVE
226
+ * fact, which is what lets the server clear a stale reason
227
+ * rather than letting one sit there being true-looking
228
+ * forever.
229
+ * · absent — an older daemon, or a poll with no admission to ask. The
230
+ * reserved meaning, and the reason nothing is sent in that
231
+ * case: silence must not be readable as "fine".
232
+ *
233
+ * `FLOWVIANT_NO_PRESSURE_GUARD` no longer silences the param, and should not:
234
+ * it turns off the MEMORY AND LOAD half, and the concurrency half it does not
235
+ * touch is still a true account of why nothing is starting. What the operator
236
+ * asked for is a box that is not second-guessed about its own memory, not an
237
+ * agent that sits still with no explanation.
238
+ *
239
+ * IT IS THE WHOLE ADMISSION, AND IT USED TO BE THE PRESSURE HALF ONLY. The
240
+ * argument for narrowing it was that "four turns are running" is a capacity
241
+ * statement — but the effect was worse than the thing it avoided: a machine
242
+ * refusing every agent turn at its ceiling sent `pr=-`, which says MEASURED
243
+ * AND FINE, so the server cleared any stored reason and the board fell
244
+ * through to "nothing has polled this turn for 12m" over a daemon that was
245
+ * polling every ten seconds and declining on purpose. The machine positively
246
+ * asserted health at the one moment it was refusing.
247
+ *
248
+ * And CLAUDE.md already carves this exact shape out: queueing is said "AT THE
249
+ * THING THAT IS WAITING, in the moment, never budgeted for in advance on a
250
+ * global chip". The relayed sentence is `admission.mjs`'s, and it names
251
+ * ACTIVITY — "the machine is already running 3 CLI turns" — never the
252
+ * ceiling, never headroom, and never anywhere but on the row that is stalled.
253
+ * That is the same shape as a CLI relaying that it hit its own limit.
254
+ *
255
+ * `URLSearchParams` does its own encoding; the slice is the belt against a
256
+ * pathological reason.
257
+ */
258
+ try {
259
+ if (churnHold !== undefined) {
260
+ url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
261
+ }
262
+ } catch {
263
+ /* a readout — the poll must never fail on one */
179
264
  }
180
265
  // An explicit User-Agent is required: Node's default ("node"/empty) trips
181
266
  // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
@@ -420,6 +505,193 @@ export function shouldStop(rosterDaemon) {
420
505
  return { stop: true, reason };
421
506
  }
422
507
 
508
+ /**
509
+ * HOW LONG AGO, in the words the standby sentence needs. Milliseconds in, one
510
+ * short label out; anything that is not a finite, non-negative number renders
511
+ * NOTHING and the caller drops the clause rather than printing "heard NaN ago".
512
+ * The three-state rule applied to a duration: measured, or say nothing.
513
+ */
514
+ export function agoLabel(ms) {
515
+ // `typeof`, not `Number()`: `Number(null)` is 0, so a coercing guard turns
516
+ // "the server said nothing" into "heard 0s ago" — a measurement nobody took,
517
+ // printed at the one moment the person is deciding whether to wait.
518
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return null;
519
+ const s = Math.round(ms / 1000);
520
+ if (s < 60) return `${s}s`;
521
+ const m = Math.round(s / 60);
522
+ if (m < 60) return `${m}m`;
523
+ const h = Math.round(m / 60);
524
+ if (h < 48) return `${h}h`;
525
+ return `${Math.round(h / 24)}d`;
526
+ }
527
+
528
+ /**
529
+ * WHOSE MACHINE THIS IS — read off the roster, said once, and never a refusal.
530
+ *
531
+ * A project has ONE machine credential and `device/approve` hands every device
532
+ * the same raw token, so two boxes running `npx flowviant` are two daemons that
533
+ * both believe they are the machine. The old behaviour was a silent race: the
534
+ * loser could not see the winner at all, and the moment the winner went quiet
535
+ * long enough for a lease to lapse it cut a FRESH branch for an agent whose work
536
+ * exists only on the other box's disk, then ran a CLI with no conversation
537
+ * behind it. A confident, context-free redo of work somebody was mid-way
538
+ * through.
539
+ *
540
+ * The server arbitrates (it is the only party that can see both boxes) and the
541
+ * answer rides the poll RESPONSE as `holder`. This turns that answer into what
542
+ * the person at the keyboard needs to know, and nothing more:
543
+ *
544
+ * · ABSENT -> this server does not arbitrate machines. Behave exactly
545
+ * as every daemon before 0.84.0 did — zero new paths.
546
+ * · mine: true -> we are the machine. Announce it only if we have been
547
+ * standing by, so an ordinary daemon prints nothing new.
548
+ * · mine: false -> STAND BY. Keep polling quietly; the restricted roster
549
+ * serves us nothing, the auto-handover makes this box the
550
+ * machine when the holder dies, and the app is where a
551
+ * person moves it sooner. Never exit: a standby that quits
552
+ * is a box somebody has to go and restart by hand.
553
+ *
554
+ * Printed ONCE PER DISTINCT HOLDER rather than per poll — a true sentence
555
+ * restated every ten seconds is a scrolling console nobody reads, and the fact
556
+ * only CHANGES when the holder does.
557
+ *
558
+ * Pure except for the injected `say`, so the whole decision can be proved
559
+ * without a credential, a server or a second box.
560
+ */
561
+ export function createHolderWatch({ say = () => {} } = {}) {
562
+ let standbyKey = null; // the holder we last announced, or null while we serve
563
+ return {
564
+ /** Returns 'absent' | 'mine' | 'standby' — the state, for the caller's
565
+ * own gating and for tests that must not read the console. */
566
+ observe(holder) {
567
+ if (!holder || typeof holder !== 'object' || Array.isArray(holder)) {
568
+ // An older server, or a poll it did not arbitrate. Silence, and not one
569
+ // new path: this is the 0.83.0 daemon.
570
+ return 'absent';
571
+ }
572
+ if (holder.mine === true) {
573
+ if (standbyKey !== null) {
574
+ standbyKey = null;
575
+ say('this machine now serves the project.');
576
+ }
577
+ return 'mine';
578
+ }
579
+ // The NAME is all the response carries about the other box, so it is also
580
+ // the only thing "a distinct holder" can be keyed on. An unnamed holder
581
+ // keys on the empty string, which is stable — one announcement, not one
582
+ // per poll.
583
+ const name = typeof holder.name === 'string' && holder.name.trim()
584
+ ? holder.name.trim().slice(0, 64)
585
+ : null;
586
+ const key = name ?? '';
587
+ if (key !== standbyKey) {
588
+ standbyKey = key;
589
+ const ago = agoLabel(holder.heardAgo);
590
+ // THE SENTENCE POINTS AT THE APP, NEVER AT A COMMAND. The owner's
591
+ // ruling, verbatim: "i dont intend to run or do anything in the
592
+ // terminal besides npx flowviant or npx flowviant login." So the
593
+ // terminal surface is those two commands, full stop — this box waits
594
+ // or a person moves the machine from project settings, and there is no
595
+ // third thing to type here. An earlier cut of this sentence ended by
596
+ // telling the person to re-run this daemon with a claim flag; that flag
597
+ // is deleted, and the gesture is the app's — where the server can see
598
+ // both boxes and every daemon learns the outcome on its next poll.
599
+ //
600
+ // The ago clause DROPS whole when unmeasured — agoLabel returns null
601
+ // rather than a zero — because "heard 0s ago" is a measurement nobody
602
+ // took, printed at the one moment the person is deciding whether to
603
+ // wait.
604
+ say(
605
+ `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
606
+ 'It moves here automatically once that machine has been quiet 10 minutes ' +
607
+ "— or move it now from the app's project settings."
608
+ );
609
+ }
610
+ return 'standby';
611
+ },
612
+ };
613
+ }
614
+
615
+ /** The sentence an in-flight agent turn is settled with when the machine moves
616
+ * out from under it. MEASURED, not inferred: the server named the box that
617
+ * took over, and an unnamed one says so rather than guessing. */
618
+ export function displacedTurnSentence(by) {
619
+ const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : 'another machine';
620
+ return `The project's machine moved to ${name} while this turn was running.`;
621
+ }
622
+
623
+ /**
624
+ * THE MACHINE MOVED. STAND DOWN.
625
+ *
626
+ * The server sends `displaced` only when it is aimed at THIS box (it matches the
627
+ * poll's own `envpub`) and only inside a short window, so there is nothing to
628
+ * re-derive here and nothing to honour from last week — the same shape, and the
629
+ * same reasoning, as the commanded stop above.
630
+ *
631
+ * Unlike the signal handlers this path is poll-response-driven, so it CAN await:
632
+ * every in-flight agent turn is settled first, because a turn this daemon
633
+ * abandons silently sits pending until the server's six-hour expiry while the
634
+ * board shows an agent working on a machine that has gone. `nothing` is the
635
+ * honest outcome and the sentence says what happened.
636
+ *
637
+ * EXIT 0, for the reason the two existing terminal paths (the commanded stop,
638
+ * and the revoked credential above it) both document: under `Restart=on-failure`
639
+ * a nonzero code has systemd relaunch this daemon immediately, where it would
640
+ * poll, be told again that it is not the machine, and stand down again — a
641
+ * restart loop fighting a decision somebody made on purpose.
642
+ *
643
+ * Every dependency is injected so the whole stand-down can be proved without a
644
+ * server, a repo or a process to kill.
645
+ */
646
+ export async function standDownDisplaced({
647
+ by,
648
+ settleAgentTurns,
649
+ flushReports,
650
+ teardown,
651
+ exit,
652
+ log = { warn: () => {}, note: () => {} },
653
+ }) {
654
+ /** A bound on a wedged uplink, and never a reason to hang: the timer is
655
+ * unref'd, so the only thing that keeps this process alive is the work. */
656
+ const bounded = (p, seconds) =>
657
+ Promise.race([
658
+ p,
659
+ new Promise((resolve) => {
660
+ const t = setTimeout(resolve, seconds * 1000);
661
+ t.unref?.();
662
+ }),
663
+ ]);
664
+ const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : null;
665
+ log.warn(
666
+ name
667
+ ? `this project's machine moved to ${name} — standing down.`
668
+ : "this project's machine moved to another box — standing down."
669
+ );
670
+ // FIRST, and awaited: an unsettled turn is the one thing here that no later
671
+ // poll from anybody can fix — this process holds the only copy of the fact
672
+ // that it was running.
673
+ try {
674
+ await bounded(settleAgentTurns(displacedTurnSentence(by)), 10);
675
+ } catch {
676
+ /* an unsettled turn expires server-side with words of its own */
677
+ }
678
+ // THEN the queued settles, bounded exactly as the commanded stop bounds them:
679
+ // a queued report is a COMPLETED turn whose side effects already happened, and
680
+ // dropping it re-runs the whole turn somewhere else.
681
+ try {
682
+ await bounded(flushReports(), 5);
683
+ } catch {
684
+ /* undelivered reports re-run; delivering them was best-effort */
685
+ }
686
+ // NOT optional, for the reason the commanded stop states: detached preview
687
+ // tunnels survive this process by design, and a public hostname pointed into a
688
+ // worktree on a box that no longer serves the project is the worst thing this
689
+ // path can leave behind.
690
+ teardown();
691
+ log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
692
+ exit(0);
693
+ }
694
+
423
695
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
424
696
  // base between tasks (fresh conversation), resume in place while on a blocker.
425
697
 
@@ -464,6 +736,11 @@ export async function runFleetDaemon() {
464
736
  // across them. See instance.mjs for why that lock is not enough on its own.
465
737
  // Same repo -> this run replaces whatever was serving it. Different repo ->
466
738
  // refused, and nothing is signalled. See instance.mjs's header for the rule.
739
+ //
740
+ // `--takeover` ARBITRATES PROCESSES ON THIS BOX and nothing more — which
741
+ // daemon serves this repo. It says nothing about which BOX serves the
742
+ // project: that is holdership, the server decides it because only the server
743
+ // can see both boxes, and a person moves it from the app.
467
744
  const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
468
745
  takeover:
469
746
  process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
@@ -831,13 +1108,35 @@ export async function runFleetDaemon() {
831
1108
  retireWorkSessions,
832
1109
  reportWorktrees,
833
1110
  shutdownWork,
1111
+ settleAgentTurns,
834
1112
  workBusy,
1113
+ admit,
1114
+ liveTurns,
835
1115
  } = createWorkManager({
836
1116
  repoRoot,
837
1117
  baseDir,
838
1118
  getBaseRef,
839
1119
  getMcpUrl: () => mcpUrl,
840
1120
  getLeaseTtl: () => leaseTtlSeconds,
1121
+ /**
1122
+ * The cartographer is a CLI turn too, and it is the one this manager cannot
1123
+ * see — it lives in this closure, not in `workChildren`. Without it the
1124
+ * machine's ceiling would be a ceiling with a hole in it: a wiki sweep over
1125
+ * a large repo is one of the heaviest turns the daemon runs.
1126
+ *
1127
+ * Read lazily (it is only ever called from the reconcile loop, long after
1128
+ * `wikiChild` is declared below), for the same reason `onRepoChanged` is a
1129
+ * callback: work.mjs is imported BY this file and cannot import back.
1130
+ *
1131
+ * `wikiBusy` COUNTS, not just the live child, and that is the wiki lane's
1132
+ * version of the reservation `admission.mjs` describes: the drain sets the
1133
+ * flag the moment it is admitted and the CLI does not exist until several
1134
+ * awaits later, so counting the child alone left a hole exactly wide enough
1135
+ * for the other lanes to spend the slot this one had already taken. The
1136
+ * drain runs at most one CLI at a time, so the flag and the child are the
1137
+ * same one turn and this can never double-count.
1138
+ */
1139
+ extraLiveTurns: () => (wikiBusy || wikiChild ? 1 : 0),
841
1140
  /**
842
1141
  * "THE REPO JUST CHANGED — look again."
843
1142
  *
@@ -1039,6 +1338,7 @@ export async function runFleetDaemon() {
1039
1338
  const wikiQueue = [];
1040
1339
  let wikiBusy = false;
1041
1340
  let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
1341
+ let wikiHoldSaidAt = 0; // last time the drain said it was waiting on the box
1042
1342
  let lastSweepAt = null; // dedup: run each Regenerate request once
1043
1343
  // …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
1044
1344
  // the server's `regen_requested_at` stays set and the roster keeps offering
@@ -1218,6 +1518,28 @@ export async function runFleetDaemon() {
1218
1518
 
1219
1519
  async function drainWiki() {
1220
1520
  if (wikiBusy || wikiQueue.length === 0) return;
1521
+ /**
1522
+ * NOT WHILE THE BOX IS UNDER PRESSURE. A sweep is a CLI reading a whole
1523
+ * repository, which is the heaviest turn the daemon runs and the one
1524
+ * nobody is waiting on — so it is the first thing to yield.
1525
+ *
1526
+ * The queue is left INTACT: nothing is claimed, nothing is consumed, and
1527
+ * the reconcile loop calls this again on its next poll. The one thing that
1528
+ * must not happen is setting `wikiBusy` and returning, which would strand
1529
+ * the drain until a restart.
1530
+ */
1531
+ const hold = admit('churn');
1532
+ if (hold) {
1533
+ // Said at most every five minutes: this runs on every poll, and a queued
1534
+ // sweep can sit through a long stretch of pressure — a line every twenty
1535
+ // seconds would be the console restating one unchanged fact all evening.
1536
+ if (Date.now() - wikiHoldSaidAt > 5 * 60_000) {
1537
+ wikiHoldSaidAt = Date.now();
1538
+ note(`${c.cyan('wiki')} ${c.dim(`— holding off: ${hold.reason}`)}`);
1539
+ }
1540
+ return;
1541
+ }
1542
+ wikiHoldSaidAt = 0;
1221
1543
  wikiBusy = true;
1222
1544
  // Held for the WHOLE drain: this loop resets the worktree between tasks, and
1223
1545
  // a consult reading it mid-reset sees files vanish under it.
@@ -1507,6 +1829,11 @@ export async function runFleetDaemon() {
1507
1829
  let rosterSig = null; // last roster membership, to log changes only
1508
1830
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
1509
1831
  let cappedWarned = false; // say once, not every reconcile, why extra lanes idle
1832
+ // WHOSE MACHINE THIS IS, as of the last poll the server arbitrated. 'absent'
1833
+ // is the reserved meaning — an older server, or a poll with no envpub — and
1834
+ // everything downstream of it must read exactly as it did before 0.84.0.
1835
+ const holderWatch = createHolderWatch({ say: (m) => note(m) });
1836
+ let holderState = 'absent';
1510
1837
 
1511
1838
  // ── Push channel: a server wake short-circuits the reconcile sleep so a job is
1512
1839
  // picked up in ~a round trip instead of on the next poll. The socket only
@@ -1556,7 +1883,15 @@ export async function runFleetDaemon() {
1556
1883
  for (;;) {
1557
1884
  let roster;
1558
1885
  try {
1559
- roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1886
+ // The churn admission, asked ONCE here and relayed as `pr`: the same
1887
+ // question every unattended lane asks a few lines later, so what the
1888
+ // board is told and what the machine then does cannot disagree.
1889
+ roster = await fetchRoster(
1890
+ buildHave(),
1891
+ livePreviewIds(),
1892
+ heldSessionIds(),
1893
+ admit('churn')
1894
+ );
1560
1895
  } catch (e) {
1561
1896
  if (e.auth) {
1562
1897
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1634,6 +1969,38 @@ export async function runFleetDaemon() {
1634
1969
  // minutes later comes up clean instead of stopping itself forever.
1635
1970
  process.exit(0);
1636
1971
  }
1972
+ /**
1973
+ * THE MACHINE MOVED OUT FROM UNDER US — checked BEFORE the version signal
1974
+ * for the reason the stop above is: `handleVersionSignal` can re-exec this
1975
+ * process, and a box that has just been displaced coming back up wearing a
1976
+ * newer version is the one outcome nobody asked for.
1977
+ *
1978
+ * The key's PRESENCE is the command, exactly as it is for a stop: the server
1979
+ * sends it only when it names THIS box's `envpub` and only inside its own
1980
+ * window, so there is no TTL to re-evaluate here and no way for a relaunch
1981
+ * to obey a displacement aimed at somebody else.
1982
+ */
1983
+ if (roster.displaced && typeof roster.displaced === 'object' && !Array.isArray(roster.displaced)) {
1984
+ await standDownDisplaced({
1985
+ by: roster.displaced.by,
1986
+ settleAgentTurns,
1987
+ flushReports: flushWorkReports,
1988
+ teardown,
1989
+ exit: (code) => process.exit(code),
1990
+ log: { warn, note },
1991
+ });
1992
+ return;
1993
+ }
1994
+ /**
1995
+ * WHOSE MACHINE THIS IS. Absent = a server that does not arbitrate, and then
1996
+ * this is a no-op and the daemon behaves exactly as 0.83.0 did.
1997
+ *
1998
+ * A standby keeps polling and keeps everything the restricted roster still
1999
+ * drives — its `activeWorkSessions` is credential-scoped and correct, so the
2000
+ * sweep below is unchanged behaviour and must not be skipped, or a standby
2001
+ * would start deleting worktrees it cannot see the tabs for.
2002
+ */
2003
+ holderState = holderWatch.observe(roster.holder);
1637
2004
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
1638
2005
  // no workers are spawned yet). If it self-updates it re-execs into the new
1639
2006
  // version and this process becomes a proxy — stop the loop.
@@ -1776,7 +2143,11 @@ export async function runFleetDaemon() {
1776
2143
  // first poll. It used to point at the Cockpit, a surface deleted
1777
2144
  // 2026-08-04 that now redirects to the Board. Say what is actually true
1778
2145
  // instead: the machine is up, and work starts in a tab.
1779
- info('Machine online. Open a tab in Flowviant Workbench to start working.');
2146
+ // …unless another box holds the machine. A standby IS connected and IS
2147
+ // polling, and saying "machine online" over a daemon the server hands
2148
+ // nothing would contradict the standby line printed a moment earlier.
2149
+ if (holderState !== 'standby')
2150
+ info('Machine online. Open a tab in Flowviant → Workbench to start working.');
1780
2151
  }
1781
2152
  // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
1782
2153
  // `rosterIds` is built from `roster.agents`, which the server sends
@@ -1785,7 +2156,11 @@ export async function runFleetDaemon() {
1785
2156
  // there session turns, ships or unsettled reports in flight?
1786
2157
  if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
1787
2158
  idleBeatAt = Date.now();
1788
- info('machine online — nothing running right now.');
2159
+ info(
2160
+ holderState === 'standby'
2161
+ ? 'standing by — another machine holds this project.'
2162
+ : 'machine online — nothing running right now.'
2163
+ );
1789
2164
  }
1790
2165
 
1791
2166
  // Living-wiki work (runs under its own minted wiki token — no agent
@@ -1831,14 +2206,23 @@ export async function runFleetDaemon() {
1831
2206
  // laptops; that is only true if the machine is visible. Per-task RSS is the
1832
2207
  // load-bearing part — "the box is full" is not actionable, "this task is
1833
2208
  // holding 9GB" is.
2209
+ //
2210
+ // …AND IT HAD NEVER BEEN POPULATED ONCE (fixed 2026-09-14). This read the
2211
+ // dispatch-era `workers` map, which nothing has `.set()` since the lane was
2212
+ // deleted on 2026-08-19 — so the list was permanently empty, the server
2213
+ // stored an empty array every poll, and the column its own handler calls
2214
+ // the load-bearing half of this report was blank on every machine that has
2215
+ // ever run. The same shape as the env rotation two blocks up, which
2216
+ // iterated the same dead map and reached no worktree at all.
2217
+ //
2218
+ // `liveTurns()` is the live registry: every CLI child this daemon is
2219
+ // holding, with the session or agent id it serves. Bounded inside the
2220
+ // snapshot, because each row costs a /proc tree walk.
1834
2221
  void reportMergeOutcome(
1835
2222
  MACHINE_URL,
1836
2223
  machineSnapshot({
1837
2224
  worktreeDir: baseDir,
1838
- tasks: [...workers].map(([, w]) => ({
1839
- intentId: w.state.intentId ?? null,
1840
- pid: w.state.child?.pid,
1841
- })),
2225
+ tasks: liveTurns().map((t) => ({ intentId: t.id, pid: t.pid })),
1842
2226
  })
1843
2227
  );
1844
2228
 
@@ -71,6 +71,13 @@
71
71
  * `--no-takeover` (or FLOWVIANT_NO_TAKEOVER=1) makes even the same-repo case
72
72
  * refuse, for anyone who wants the old ceremony.
73
73
  *
74
+ * EVERYTHING IN THIS FILE IS ABOUT PROCESSES ON ONE BOX, decided locally against
75
+ * a lock file. It says nothing about WHICH BOX serves the project — a project
76
+ * has one machine and the SERVER arbitrates that, because it is the only party
77
+ * that can see both boxes. A standby daemon here is a perfectly healthy holder
78
+ * of this repo's lock; the two questions never meet, and no flag in this file
79
+ * moves the project's machine.
80
+ *
74
81
  * IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
75
82
  * to start; it is a reason to say so and carry on unguarded.
76
83
  */