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/README.md +13 -0
- package/bin/lib/admission.mjs +116 -0
- package/bin/lib/config.mjs +44 -4
- package/bin/lib/env.mjs +24 -3
- package/bin/lib/fleet.mjs +393 -9
- package/bin/lib/instance.mjs +7 -0
- package/bin/lib/resources.mjs +226 -1
- package/bin/lib/runtimes.mjs +25 -0
- package/bin/lib/trace.mjs +194 -0
- package/bin/lib/work.mjs +440 -9
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -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
|
*/
|