flowviant 0.88.0 → 0.89.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.
@@ -23,7 +23,16 @@
23
23
  * ── WHAT THIS IS NOT ──
24
24
  *
25
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
26
+ * the only time anyone hears about it is the moment it actually fires.
27
+ *
28
+ * NARROWED 2026-09-17, knowingly. The effective ceiling now rides the poll as
29
+ * `mt` and is shown in two places, and neither is a meter: project SETTINGS,
30
+ * beside the control that sets it — a dial has to say what it is currently
31
+ * worth or it is a control with no readout — and on the board ONLY while a real
32
+ * agent is being deferred, which is the "at the thing that is waiting, in the
33
+ * moment" carve-out this product already grants. What stays forbidden is the
34
+ * resting global chip and any statement of HEADROOM: "room for N more" is still
35
+ * dead, and the refusal below still names ACTIVITY and never the bound. It does
27
36
  * not kill anything — no signal is sent on Flowviant's initiative, ever. It
28
37
  * does not park an agent: a park needs a human gesture to lift, and pressure
29
38
  * clears on its own, so a deferral is simply a spawn that did not happen this
@@ -70,12 +79,98 @@
70
79
  * idempotent so doing both is the normal case rather than a bug.
71
80
  */
72
81
 
73
- import { MAX_CONCURRENT } from './config.mjs';
82
+ import { MAX_CONCURRENT, MAX_CONCURRENT_FROM_ENV } from './config.mjs';
74
83
  import { pressureVerdict } from './resources.mjs';
75
84
 
85
+ /**
86
+ * ── THE CEILING HAS THREE POSSIBLE AUTHORS (2026-09-17) ──
87
+ *
88
+ * The owner's box derived ONE, so every parallel agent serialized here with
89
+ * nothing but a per-agent pulse line to explain it: "thats why i was
90
+ * immediately confused. theres nothing telling me that i could only have one
91
+ * agent on the board." His fix was to move the dial into the product — "it
92
+ * shouldnt be a variable on the npx to make it friendly for non tech users. why
93
+ * cant it be on the web interface?" — so the roster reply may now carry
94
+ * `maxTurns`.
95
+ *
96
+ * THE ORDER IS ENV > SERVER > DERIVED, and the middle one is the new arrival:
97
+ *
98
+ * · ENV WINS because an operator who typed `FLOWVIANT_MAX_CONCURRENT=2` at the
99
+ * box is stating their last word about their own machine, and a control they
100
+ * cannot see must not silently overrule it. This is the same posture the
101
+ * daemon keeps everywhere else it is handed an instruction: the app decides
102
+ * the product, the box decides the box.
103
+ * · THE SERVER'S DIAL beats the derivation because the app is where every
104
+ * decision in this product is made, and the derivation is a GUESS about
105
+ * hardware — a good one, but one a person looking at their own machine is
106
+ * entitled to overrule without opening a terminal.
107
+ * · THE DERIVATION is the resting state, and it is what a project that has
108
+ * never touched the dial gets. `null` — not 0, not NaN — is what "nobody
109
+ * said" looks like, so the fallback is reached by ABSENCE rather than by a
110
+ * sentinel number that could be mistaken for a bound.
111
+ *
112
+ * Pure, and exported, so the order can be proved without a server, a poll or a
113
+ * box of any particular size.
114
+ */
115
+ export function pickMaxTurns({ env = null, server = null, derived = 1 } = {}) {
116
+ const clamp = (n) => {
117
+ const v = Number(n);
118
+ if (!Number.isFinite(v) || v < 1) return null;
119
+ return Math.min(Math.floor(v), 32);
120
+ };
121
+ return clamp(env) ?? clamp(server) ?? clamp(derived) ?? 1;
122
+ }
123
+
124
+ /**
125
+ * THE LAST NUMBER THE SERVER NAMED, or null.
126
+ *
127
+ * A module holder rather than a value threaded through `createWorkManager`,
128
+ * because the reader and the writer are two files apart and one poll apart: the
129
+ * POLL learns it (fleet.mjs) and every ADMISSION reads it, including the ones
130
+ * work.mjs takes in lanes fleet.mjs never sees. The alternative — passing it
131
+ * down — would mean the number that bound a spawn was whatever was current when
132
+ * the manager was CONSTRUCTED, which is exactly the frozen-at-startup bug
133
+ * `getBaseRef` and `getLeaseTtl` are getters to avoid.
134
+ */
135
+ let serverMaxTurns = null;
136
+
137
+ /**
138
+ * Record what the roster said. ANYTHING THAT IS NOT A USABLE NUMBER CLEARS IT,
139
+ * and both directions of that matter:
140
+ *
141
+ * · a MALFORMED value must not become a ceiling — `Number('')` is 0 and a
142
+ * ceiling of 0 refuses every spawn forever, which is a machine that has
143
+ * silently stopped working;
144
+ * · an ABSENT value is how "Auto" is spelled on the wire (the server simply
145
+ * does not send the key), and how an older SERVER looks. Both mean the
146
+ * derivation stands, so absence has to clear a value set by an earlier poll
147
+ * rather than leaving the last dial standing forever — otherwise turning the
148
+ * dial back to Auto would be unspellable.
149
+ */
150
+ export function setServerMaxTurns(raw) {
151
+ const v = Number(raw);
152
+ serverMaxTurns = Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), 32) : null;
153
+ return serverMaxTurns;
154
+ }
155
+
156
+ /** THE NUMBER IN FORCE RIGHT NOW — read per admission, and reported to the app
157
+ * on the poll as `mt`, because it is the bound the refusal is about. */
158
+ export function effectiveMaxTurns() {
159
+ return pickMaxTurns({
160
+ env: MAX_CONCURRENT_FROM_ENV ? MAX_CONCURRENT : null,
161
+ server: serverMaxTurns,
162
+ derived: MAX_CONCURRENT,
163
+ });
164
+ }
165
+
76
166
  export function createAdmission({
77
167
  liveTurnCount,
78
- maxConcurrent = MAX_CONCURRENT,
168
+ /** A NUMBER OR A FUNCTION. The function form is the live one: the ceiling can
169
+ * move between two polls now, so a value captured when the manager was built
170
+ * would be the dial as it stood at daemon start for the rest of the process.
171
+ * A number is still accepted, and every test here passes one — the whole
172
+ * point of injecting it. */
173
+ maxConcurrent = effectiveMaxTurns,
79
174
  verdict = pressureVerdict,
80
175
  } = {}) {
81
176
  /** Slots taken by a decision whose process does not exist yet. A Set of
@@ -88,7 +183,18 @@ export function createAdmission({
88
183
  function admit(level) {
89
184
  const counted = Number(liveTurnCount?.() ?? 0);
90
185
  const live = (Number.isFinite(counted) ? counted : 0) + holding.size;
91
- if (live >= maxConcurrent)
186
+ /**
187
+ * READ PER ADMISSION, and falling back to the DERIVED number rather than to
188
+ * nothing. A ceiling that comes out NaN is not a permissive ceiling, it is
189
+ * NO ceiling — `live >= NaN` is false every time — which is precisely the
190
+ * unguarded spawn loop that froze somebody's computer. The local derivation
191
+ * is the one value in this process that cannot be garbage.
192
+ */
193
+ const asked =
194
+ typeof maxConcurrent === 'function' ? Number(maxConcurrent()) : Number(maxConcurrent);
195
+ const ceiling =
196
+ Number.isFinite(asked) && asked >= 1 ? Math.min(Math.floor(asked), 32) : MAX_CONCURRENT;
197
+ if (live >= ceiling)
92
198
  return {
93
199
  reason: `the machine is already running ${live} CLI turn${live === 1 ? '' : 's'}`,
94
200
  };
@@ -118,6 +118,16 @@ 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
+ * …AND SINCE 2026-09-17 THE APP MAY NAME A NUMBER TOO, which narrows that
122
+ * sentence rather than reversing it. What stays true is that the DERIVATION
123
+ * belongs here and that the enforcement is local: a ceiling that only exists as
124
+ * a request is not one. What changed is that a person may now say "run two at
125
+ * once" without going to the box, because the owner's answer to being stuck at
126
+ * one was "it shouldnt be a variable on the npx to make it friendly for non
127
+ * tech users. why cant it be on the web interface?". The precedence — env, then
128
+ * the app's dial, then this derivation — lives in `pickMaxTurns`
129
+ * (admission.mjs), and this value is what that falls back to.
130
+ *
121
131
  * Sent to the server on every roster poll so it can pace what it offers UNDER
122
132
  * this ceiling, and ENFORCED LOCALLY BESIDES — a ceiling that only exists as a
123
133
  * request is not one, and the roster can always offer more than this.
@@ -142,23 +152,56 @@ export const MACHINE = machineLimits();
142
152
  *
143
153
  * MEMORY is the bound, not cores. Cores oversubscribe gracefully (everything
144
154
  * gets slower); memory does not (something dies, and not necessarily the
145
- * offender). A task is a Claude session plus a dev server plus whatever the
146
- * test runner spawns — call it 2GB, keep 2GB back for the operating system,
147
- * and let cores cap it only when they are the scarcer thing.
148
- *
149
- * This used to be `min(4, cores/2)` half the cores because "the user is
150
- * working on this machine too", and a hard 4 because a personal Claude plan
151
- * ran out before the CPU did. Both were laptop assumptions. The machine is now
152
- * a box the project leaves running, so it reserves one core rather than half of
153
- * them, and the ceiling is what the hardware can hold rather than a guess about
154
- * somebody's plan.
155
+ * offender).
156
+ *
157
+ * BOTH HALVES WERE RETUNED 2026-09-17, after the owner's box computed ONE and
158
+ * he met it as "theres nothing telling me that i could only have one agent on
159
+ * the board". The old numbers were `floor((memGB − 2) / 2)` and `cores 1`,
160
+ * and each was a guess this file had stopped examining:
161
+ *
162
+ * · 2GB PER TURN assumed every turn drags a dev server and a test runner
163
+ * behind it. Most do not an agent turn is a CLI that reads, edits and
164
+ * commits, and it is API-bound for nearly all of its life. 1GB per turn with
165
+ * the same 2GB held back for the operating system is the honest sizing, and
166
+ * the PRESSURE guard (resources.mjs) is what catches the turn that really
167
+ * does hold gigabytes: it measures at the moment of spawning, which is a
168
+ * thing a static divisor cannot do.
169
+ * · `cores − 1` is the laptop assumption this comment already claims to have
170
+ * retired, reborn one line down. The paragraph above says cores
171
+ * OVERSUBSCRIBE GRACEFULLY and memory is what does not — so cores must not
172
+ * be the half that binds first. On a 2-core VM `cores − 1` was 1, and that
173
+ * single subtraction is the whole reason the owner's machine serialized
174
+ * every agent. `cores * 2` lets the scheduler do what it is for, and memory
175
+ * stays the bound that actually refuses.
176
+ *
177
+ * `min(32, …)` and the env override are untouched: the hard 32 is a runaway
178
+ * bound on a box, not a product decision, and an operator who typed a number at
179
+ * the machine gets exactly that number.
180
+ */
181
+ export function deriveMaxConcurrent(memBytes, cores) {
182
+ const byMem = Math.floor(memBytes / 2 ** 30 - 2);
183
+ const byCpu = Number(cores) * 2;
184
+ return Math.max(1, Math.min(32, byMem, byCpu));
185
+ }
186
+
187
+ /**
188
+ * WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
189
+ * reconstruct afterwards.
190
+ *
191
+ * `FLOWVIANT_MAX_CONCURRENT=4` and a box that happens to derive 4 produce the
192
+ * identical number, and they are not the same statement: one is an operator's
193
+ * last word and must outrank the app's dial, the other is a default the app may
194
+ * override. See `pickMaxTurns` in admission.mjs for what is done with it.
155
195
  */
196
+ export const MAX_CONCURRENT_FROM_ENV = (() => {
197
+ const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
198
+ return Number.isFinite(asked) && asked >= 1;
199
+ })();
200
+
156
201
  export const MAX_CONCURRENT = (() => {
157
202
  const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
158
203
  if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
159
- const byMem = Math.floor((MACHINE.memBytes / 2 ** 30 - 2) / 2);
160
- const byCpu = MACHINE.cores - 1;
161
- return Math.max(1, Math.min(32, byMem, byCpu));
204
+ return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
162
205
  })();
163
206
  export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
164
207
  // Live mode: after this long idle-parked on a blocker, tear the session down to
package/bin/lib/fleet.mjs CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  THINK_MARKER,
86
86
  } from './runtimes.mjs';
87
87
  import { createWorkManager } from './work.mjs';
88
+ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
88
89
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
90
  import { repoState } from './repoState.mjs';
90
91
  import { claudeAuthContext } from './claudeAuth.mjs';
@@ -259,6 +260,34 @@ async function fetchRoster(
259
260
  try {
260
261
  if (churnHold !== undefined) {
261
262
  url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
263
+ /**
264
+ * …AND THE BOUND THE REFUSAL IS ABOUT (2026-09-17, `mt`).
265
+ *
266
+ * THIS AMENDS THE PARAGRAPH DIRECTLY ABOVE, which says the relayed
267
+ * sentence "names ACTIVITY … never the ceiling, never headroom". That
268
+ * rule was written to stop a capacity meter, and it succeeded so
269
+ * completely that a machine which could only ever run ONE turn had no way
270
+ * to say so — the owner, verbatim: "we should have ui indicating this on
271
+ * the board view and a ui indicating or showing the agent as waiting or
272
+ * queued as a result of that resource constraint. thats why i was
273
+ * immediately confused. theres nothing telling me that i could only have
274
+ * one agent on the board."
275
+ *
276
+ * So the ceiling crosses the wire, and the narrow shape that survives is
277
+ * the same carve-out the sentence beside it already lives under: it is
278
+ * shown in SETTINGS (beside the dial that sets it — a control with no
279
+ * readout is not a control) and on the BOARD only while a real agent is
280
+ * actually being deferred. Never a resting chip, and never headroom:
281
+ * nothing anywhere subtracts this from the live count to advertise room.
282
+ *
283
+ * It rides the `pr` gate deliberately — this is the bound that admission
284
+ * measured against, so a poll with no admission to ask has no effective
285
+ * ceiling to report either, and absence keeps meaning "an older daemon".
286
+ * `capacity` above is the DERIVED number and predates the dial; it is a
287
+ * dispatch-era fossil the server ignores, and the two are not the same
288
+ * fact.
289
+ */
290
+ url.searchParams.set('mt', String(effectiveMaxTurns()));
262
291
  }
263
292
  } catch {
264
293
  /* a readout — the poll must never fail on one */
@@ -1933,6 +1962,20 @@ export async function runFleetDaemon() {
1933
1962
  }
1934
1963
  }
1935
1964
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
1965
+ /**
1966
+ * HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
1967
+ *
1968
+ * Set on EVERY poll, including the ones that carry no key — absence is how
1969
+ * "Auto" is spelled and how an older server looks, and both mean the
1970
+ * derivation stands. Leaving a previous value in place on an absent key
1971
+ * would make turning the dial back to Auto unspellable, which is the
1972
+ * learn-only bug `listSessionPlaces` already paid for once.
1973
+ *
1974
+ * HERE, ABOVE EVERY LANE, so a number that arrived on this poll binds the
1975
+ * spawns this same reconcile is about to decide. The `mt` the NEXT poll
1976
+ * reports is therefore the value that was actually in force.
1977
+ */
1978
+ setServerMaxTurns(roster.maxTurns);
1936
1979
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
1937
1980
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
1938
1981
  // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {