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 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
+ }
@@ -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 grow lanes to meet waiting
122
- * work UNDER this ceiling, and enforced locally besidesthe roster can carry
123
- * more lanes than this, and a ceiling that only exists as a request is not one.
121
+ * Sent to the server on every roster poll so it can pace what it offers UNDER
122
+ * this ceiling, and ENFORCED LOCALLY BESIDESa 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
- const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
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 });