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/README.md
CHANGED
|
@@ -66,6 +66,19 @@ The daemon never executes anything the repository declares. An earlier version r
|
|
|
66
66
|
| _(stored login)_ or `FLOWVIANT_FLEET` | **the daemon** — the project's machine, serving its sessions |
|
|
67
67
|
| `FLOWVIANT_SAFE=1` | restrict the toolset instead of running unattended |
|
|
68
68
|
|
|
69
|
+
## Not freezing the box
|
|
70
|
+
|
|
71
|
+
Since 0.83.0 the daemon looks at the machine before it starts another CLI. It counts the turns it is already running against `FLOWVIANT_MAX_CONCURRENT` (default: what the box's memory and cores can hold), and reads free memory and load. When either says no it simply does not spawn — the job stays queued server-side and is offered again on the next poll, and the app is told the machine's own measured reason ("low memory — 612 MB of 16.0 GB available") at whatever is waiting. Nothing is ever killed, and nothing is parked; pressure clears on its own.
|
|
72
|
+
|
|
73
|
+
Unattended work (agent turns, a Deploy plan, the wiki sweep) yields first. A Workbench turn — somebody watching a composer — holds out until the box is genuinely about to fall over.
|
|
74
|
+
|
|
75
|
+
| Env | Default | What it does |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `FLOWVIANT_MIN_FREE_MB` | `1024` | free memory below this (or below 6% of total, whichever is larger) defers unattended work |
|
|
78
|
+
| `FLOWVIANT_MAX_LOAD_PER_CORE` | `4` | 1-minute load above `cores × this` defers unattended work |
|
|
79
|
+
| `FLOWVIANT_CRITICAL_FREE_MB` | `400` | free memory below this defers a session turn too |
|
|
80
|
+
| `FLOWVIANT_NO_PRESSURE_GUARD=1` | — | turn the memory/load guard off entirely (the concurrency ceiling still holds) |
|
|
81
|
+
|
|
69
82
|
## Security posture
|
|
70
83
|
|
|
71
84
|
Every project member with edit access can run turns on this machine — a
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAY THIS MACHINE START ONE MORE CLI RIGHT NOW?
|
|
3
|
+
*
|
|
4
|
+
* One question, asked at every spawn point, answering `null` (go ahead) or
|
|
5
|
+
* `{ reason }` — the machine's own measured sentence, relayed at the thing that
|
|
6
|
+
* is waiting and nowhere else.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS EXISTS ──
|
|
9
|
+
*
|
|
10
|
+
* Flowviant froze somebody's computer. Three separate holes, all of them the
|
|
11
|
+
* same shape — a bound that had been written down and then stopped being
|
|
12
|
+
* enforced:
|
|
13
|
+
*
|
|
14
|
+
* · `MAX_CONCURRENT` (config.mjs) is computed from the box's real memory and
|
|
15
|
+
* cores, sent to the server on every poll, and was enforced NOWHERE. Its
|
|
16
|
+
* local enforcement lived in the dispatch lane and was deleted with it; the
|
|
17
|
+
* comment claiming otherwise outlived the code by a month.
|
|
18
|
+
* · `processWorkTurns` had no slice, so every session turn the roster offered
|
|
19
|
+
* started in one tick.
|
|
20
|
+
* · agent turns start four per tick, forever, and nothing looked at memory
|
|
21
|
+
* before spawning a process that routinely holds gigabytes.
|
|
22
|
+
*
|
|
23
|
+
* ── WHAT THIS IS NOT ──
|
|
24
|
+
*
|
|
25
|
+
* It is not a capacity meter: nothing is published ahead of the decision, and
|
|
26
|
+
* the only time anyone hears about it is the moment it actually fires. It does
|
|
27
|
+
* not kill anything — no signal is sent on Flowviant's initiative, ever. It
|
|
28
|
+
* does not park an agent: a park needs a human gesture to lift, and pressure
|
|
29
|
+
* clears on its own, so a deferral is simply a spawn that did not happen this
|
|
30
|
+
* tick. And it never settles the job it defers — the server re-offers on the
|
|
31
|
+
* next poll, which is the whole reason declining is safe.
|
|
32
|
+
*
|
|
33
|
+
* ── ORDER, AND WHY CAPACITY IS ASKED FIRST ──
|
|
34
|
+
*
|
|
35
|
+
* The count is ours and exact; the pressure reading is the box's and a couple
|
|
36
|
+
* of seconds old. When both would refuse, the exact one is the honest sentence.
|
|
37
|
+
*
|
|
38
|
+
* The capacity reason DOES ride the roster's `pr` relay, and the clause here
|
|
39
|
+
* used to say it must not — "four turns are running is activity, which the
|
|
40
|
+
* board already shows". The board shows AGENTS; it has never shown a Workbench
|
|
41
|
+
* turn or the wiki cartographer, both of which hold this ceiling, and the law
|
|
42
|
+
* those words come from says the opposite of what they were used for: queueing
|
|
43
|
+
* is said "AT THE THING THAT IS WAITING, in the moment, never budgeted for in
|
|
44
|
+
* advance on a global chip". The sentence below names ACTIVITY and never the
|
|
45
|
+
* ceiling, and it is relayed to exactly one place — the row that is stalled.
|
|
46
|
+
* Withholding it meant the poll sent `pr=-`, i.e. MEASURED AND FINE, at the
|
|
47
|
+
* precise moment the machine was refusing everything.
|
|
48
|
+
*
|
|
49
|
+
* ── THE COUNT MUST NOT GO STALE INSIDE A TICK ──
|
|
50
|
+
*
|
|
51
|
+
* Every spawn in this daemon is ASYNCHRONOUS and every lane loop is not: the
|
|
52
|
+
* session-turn loop runs `inPlace(...)` per job without awaiting it, so the
|
|
53
|
+
* child does not reach `workChildren` until a later microtask, and the loop
|
|
54
|
+
* asked `liveTurnCount()` again on the very next line. The number could not
|
|
55
|
+
* move, so `0 >= 1` was false eight times in a row and a box with a ceiling of
|
|
56
|
+
* one started eight session turns, four agent turns, the planner and the wiki
|
|
57
|
+
* sweep in a single reconcile — the guard biting only on the NEXT tick, after
|
|
58
|
+
* the box was already loaded. That is the freeze this file exists to prevent,
|
|
59
|
+
* re-armed and still open.
|
|
60
|
+
*
|
|
61
|
+
* A RESERVATION closes it. `admit.reserve()` is taken the moment a job is
|
|
62
|
+
* admitted and released when its child registers (or when the job ends without
|
|
63
|
+
* one), so the very next `admit` in the same tick counts the spawn that has
|
|
64
|
+
* been decided on but not yet happened. It is the count made honest about the
|
|
65
|
+
* future it has already committed to — not a queue, not a budget, and never
|
|
66
|
+
* published: nothing outside this module can read the number.
|
|
67
|
+
*
|
|
68
|
+
* A LEAKED RESERVATION WOULD SHRINK THE CEILING FOREVER, so every caller
|
|
69
|
+
* releases in a `finally` as well as at the spawn, and the release is
|
|
70
|
+
* idempotent so doing both is the normal case rather than a bug.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { MAX_CONCURRENT } from './config.mjs';
|
|
74
|
+
import { pressureVerdict } from './resources.mjs';
|
|
75
|
+
|
|
76
|
+
export function createAdmission({
|
|
77
|
+
liveTurnCount,
|
|
78
|
+
maxConcurrent = MAX_CONCURRENT,
|
|
79
|
+
verdict = pressureVerdict,
|
|
80
|
+
} = {}) {
|
|
81
|
+
/** Slots taken by a decision whose process does not exist yet. A Set of
|
|
82
|
+
* tokens rather than a counter: a double release then costs nothing, which
|
|
83
|
+
* is what lets a caller release at the spawn AND in its `finally`. */
|
|
84
|
+
const holding = new Set();
|
|
85
|
+
|
|
86
|
+
/** `level` is 'churn' (unattended lanes) or 'interactive' (a session turn
|
|
87
|
+
* somebody is watching). See pressureVerdict for why the two differ. */
|
|
88
|
+
function admit(level) {
|
|
89
|
+
const counted = Number(liveTurnCount?.() ?? 0);
|
|
90
|
+
const live = (Number.isFinite(counted) ? counted : 0) + holding.size;
|
|
91
|
+
if (live >= maxConcurrent)
|
|
92
|
+
return {
|
|
93
|
+
reason: `the machine is already running ${live} CLI turn${live === 1 ? '' : 's'}`,
|
|
94
|
+
};
|
|
95
|
+
return verdict(level) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* TAKE THE SLOT THIS ADMISSION JUST GRANTED, and hand back the release.
|
|
100
|
+
*
|
|
101
|
+
* Called only after `admit` has answered null, by every caller that then goes
|
|
102
|
+
* on to spawn. It is deliberately a second call rather than something `admit`
|
|
103
|
+
* returns: `admit` answers a QUESTION and several of its callers ask it in
|
|
104
|
+
* places where nothing is about to be started.
|
|
105
|
+
*/
|
|
106
|
+
admit.reserve = () => {
|
|
107
|
+
const token = {};
|
|
108
|
+
holding.add(token);
|
|
109
|
+
return () => holding.delete(token);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** For tests: slots taken and not yet released. Never relayed anywhere. */
|
|
113
|
+
admit.reserved = () => holding.size;
|
|
114
|
+
|
|
115
|
+
return admit;
|
|
116
|
+
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { cpus, totalmem } from 'node:os';
|
|
7
|
+
import { cpus, hostname, totalmem } from 'node:os';
|
|
8
8
|
|
|
9
9
|
// Read the daemon's version from its OWN package.json (always shipped in the npm
|
|
10
10
|
// tarball) — never hardcode it. The hardcoded constant drifted: it sat at
|
|
@@ -118,9 +118,27 @@ export const MACHINE = machineLimits();
|
|
|
118
118
|
* tests want, and this process is the only party that can see the cores, the
|
|
119
119
|
* RAM and the fan.
|
|
120
120
|
*
|
|
121
|
-
* Sent to the server on every roster poll so it can
|
|
122
|
-
*
|
|
123
|
-
*
|
|
121
|
+
* Sent to the server on every roster poll so it can pace what it offers UNDER
|
|
122
|
+
* this ceiling, and ENFORCED LOCALLY BESIDES — a ceiling that only exists as a
|
|
123
|
+
* request is not one, and the roster can always offer more than this.
|
|
124
|
+
*
|
|
125
|
+
* That second clause was FALSE for a month and this comment went on asserting
|
|
126
|
+
* it (found 2026-09-14, after the daemon froze somebody's computer). The local
|
|
127
|
+
* enforcement lived in the dispatch lane's claim path and was deleted with
|
|
128
|
+
* dispatch on 2026-08-19; nothing replaced it, so every lane that spawns a CLI
|
|
129
|
+
* — session turns with no slice at all, four agent turns a tick, the planner,
|
|
130
|
+
* the cartographer — started whatever it was handed. It is true again:
|
|
131
|
+
* `admission.mjs` counts the live CLI children across every lane and refuses a
|
|
132
|
+
* new spawn at this number, deferring the job rather than settling it. A stale
|
|
133
|
+
* comment describing a guard that is not there reads, to the next person,
|
|
134
|
+
* exactly like a guard that holds — which is how this survived so long.
|
|
135
|
+
*
|
|
136
|
+
* AND IT BINDS WITHIN ONE TICK, which the first cut did not. Every lane loop is
|
|
137
|
+
* synchronous while every spawn under it is not, so the child registry could
|
|
138
|
+
* not grow between iterations and a single reconcile still admitted everything
|
|
139
|
+
* it was offered against the count it started with — a ceiling that only bit on
|
|
140
|
+
* the NEXT tick, after the box was already loaded. `admission.mjs` reserves the
|
|
141
|
+
* slot at the decision; the reservation argument lives there.
|
|
124
142
|
*
|
|
125
143
|
* MEMORY is the bound, not cores. Cores oversubscribe gracefully (everything
|
|
126
144
|
* gets slower); memory does not (something dies, and not necessarily the
|
|
@@ -181,6 +199,28 @@ export const USER_AGENT = `flowviant/${VERSION}`;
|
|
|
181
199
|
*/
|
|
182
200
|
export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
|
|
183
201
|
|
|
202
|
+
/**
|
|
203
|
+
* WHICH BOX, in words a person recognises — and nothing else.
|
|
204
|
+
*
|
|
205
|
+
* Identity for arbitration is the env keypair's public key (`envpub`), which is
|
|
206
|
+
* durable per box and already on every poll. This is the LABEL beside it: the
|
|
207
|
+
* app has to be able to say "your machine is mac-mini" rather than "your machine
|
|
208
|
+
* is a base64 string", and a box that cannot be named is the one thing a
|
|
209
|
+
* standby daemon's whole sentence is about.
|
|
210
|
+
*
|
|
211
|
+
* Capped like every other bounded write that leaves this process — a query
|
|
212
|
+
* string is not a log — and null rather than '' when the host has no name, so
|
|
213
|
+
* the param is ABSENT and reads as an older daemon instead of as a box called
|
|
214
|
+
* nothing.
|
|
215
|
+
*/
|
|
216
|
+
export const MACHINE_HOST = (() => {
|
|
217
|
+
try {
|
|
218
|
+
return String(hostname() || '').trim().slice(0, 64) || null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
|
|
184
224
|
// The ONE credential. `tokens` (FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / --token /
|
|
185
225
|
// --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
|
|
186
226
|
// that principal owns zero tools since dispatch was deleted, and the kind can no
|
package/bin/lib/env.mjs
CHANGED
|
@@ -98,15 +98,36 @@ export function pubkeyEmoji(pubkeyB64) {
|
|
|
98
98
|
export async function ensureKeypair() {
|
|
99
99
|
await sodium.ready;
|
|
100
100
|
if (keypair) return keypair;
|
|
101
|
+
let raw = null;
|
|
101
102
|
try {
|
|
102
|
-
|
|
103
|
+
raw = readFileSync(KEYPAIR_PATH, 'utf8');
|
|
104
|
+
} catch (e) {
|
|
105
|
+
/**
|
|
106
|
+
* ONLY "THERE IS NO FILE" IS A FIRST RUN, and the bare catch that used to
|
|
107
|
+
* stand here said every failure was one.
|
|
108
|
+
*
|
|
109
|
+
* This keypair is the box's DURABLE IDENTITY — it is what the project's
|
|
110
|
+
* private key is sealed to, and since 2026-09-14 it is also what tells two
|
|
111
|
+
* computers apart when the server decides which of them is this project's
|
|
112
|
+
* machine. Regenerating it on a truncated file or an unreadable one
|
|
113
|
+
* OVERWRITES that identity: the wraps stop opening, and the box arrives at
|
|
114
|
+
* the roster as a stranger and stands itself down as a standby of itself.
|
|
115
|
+
*
|
|
116
|
+
* A file we cannot read is not a file we may replace. Rethrown, the caller
|
|
117
|
+
* that can survive it does: `envQueryParams` is wrapped, so the poll simply
|
|
118
|
+
* carries no `envpub`, and a poll with no envpub is EXEMPT from arbitration
|
|
119
|
+
* — the documented fail-open arm, reached honestly instead of by minting a
|
|
120
|
+
* new box every restart.
|
|
121
|
+
*/
|
|
122
|
+
if (e?.code !== 'ENOENT') throw e;
|
|
123
|
+
}
|
|
124
|
+
if (raw !== null) {
|
|
125
|
+
const stored = JSON.parse(raw);
|
|
103
126
|
keypair = {
|
|
104
127
|
publicKey: sodium.from_base64(stored.pub, B64()),
|
|
105
128
|
privateKey: sodium.from_base64(stored.priv, B64()),
|
|
106
129
|
};
|
|
107
130
|
return keypair;
|
|
108
|
-
} catch {
|
|
109
|
-
/* first run */
|
|
110
131
|
}
|
|
111
132
|
keypair = sodium.crypto_box_keypair();
|
|
112
133
|
mkdirSync(dirname(KEYPAIR_PATH), { recursive: true });
|