flowviant 0.88.0 → 0.90.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/admission.mjs +110 -4
- package/bin/lib/claude.mjs +60 -3
- package/bin/lib/config.mjs +56 -13
- package/bin/lib/fleet.mjs +43 -0
- package/bin/lib/work.mjs +64 -1
- package/package.json +1 -1
package/bin/lib/admission.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -168,6 +168,43 @@ export const humanizeToolUse = humanizeClaudeTool;
|
|
|
168
168
|
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
169
169
|
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
|
|
173
|
+
*
|
|
174
|
+
* The `result` event carries a `usage` object — `input_tokens`,
|
|
175
|
+
* `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
|
|
176
|
+
* and this reads exactly those four and nothing else. A RELAY: nothing here
|
|
177
|
+
* derives, estimates or prices anything.
|
|
178
|
+
*
|
|
179
|
+
* DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
|
|
180
|
+
* notional list price that a subscription operator did not pay, so relaying it
|
|
181
|
+
* as "cost" would be the product asserting a figure nobody was charged — the
|
|
182
|
+
* refusal the board's fix pass already made once against the mock's "1.4
|
|
183
|
+
* spent". Tokens are measured; dollars are not.
|
|
184
|
+
*
|
|
185
|
+
* COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
|
|
186
|
+
* negative reads as 0, because these are summed into a counter that only ever
|
|
187
|
+
* goes up and one bad field must not poison the other three.
|
|
188
|
+
*
|
|
189
|
+
* Returns null when there is no usage object at all — the three-state rule
|
|
190
|
+
* every readout here keeps: a turn that reported nothing charges nothing, and
|
|
191
|
+
* that is not the same as a turn that reported zeros.
|
|
192
|
+
*/
|
|
193
|
+
export function usageFromResult(ev) {
|
|
194
|
+
const u = ev?.usage;
|
|
195
|
+
if (!u || typeof u !== 'object') return null;
|
|
196
|
+
const n = (v) => {
|
|
197
|
+
const x = Number(v);
|
|
198
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
input: n(u.input_tokens),
|
|
202
|
+
output: n(u.output_tokens),
|
|
203
|
+
cacheCreate: n(u.cache_creation_input_tokens),
|
|
204
|
+
cacheRead: n(u.cache_read_input_tokens),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
171
208
|
// Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
|
|
172
209
|
// Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
|
|
173
210
|
// daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
|
|
@@ -180,7 +217,15 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
|
|
|
180
217
|
// every intermediate text block still NARRATES, but only the final `result`
|
|
181
218
|
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
182
219
|
// they stream and once in the result, and the tab posts the duplicate.
|
|
183
|
-
|
|
220
|
+
//
|
|
221
|
+
// EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
|
|
222
|
+
// `onUsage` threading through this function, `runTurn`'s options and `onLine`
|
|
223
|
+
// was four edits and NOT ONE of them was reachable from either suite — delete
|
|
224
|
+
// any one and both stay green, while the only symptom in production is a
|
|
225
|
+
// container that reports no spend, which is indistinguishable from an older
|
|
226
|
+
// daemon by design. A callback that is silent when it breaks has to be called
|
|
227
|
+
// directly by something.
|
|
228
|
+
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
|
|
184
229
|
let ev;
|
|
185
230
|
try {
|
|
186
231
|
ev = JSON.parse(line);
|
|
@@ -262,6 +307,18 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
262
307
|
});
|
|
263
308
|
}
|
|
264
309
|
} else if (ev.type === 'result') {
|
|
310
|
+
/**
|
|
311
|
+
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
312
|
+
*
|
|
313
|
+
* FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
|
|
314
|
+
* hit a limit, ran out of permission or aborted still sent the requests it
|
|
315
|
+
* sent, and a spend readout that quietly skipped every unhappy turn would
|
|
316
|
+
* under-report exactly the runs somebody is looking at the number to
|
|
317
|
+
* understand. `usageFromResult` returns null when the event carries no
|
|
318
|
+
* usage at all, and a caller that passes no `onUsage` sees no change.
|
|
319
|
+
*/
|
|
320
|
+
const usage = usageFromResult(ev);
|
|
321
|
+
if (usage) onUsage?.(usage);
|
|
265
322
|
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
266
323
|
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
267
324
|
else if (ev.is_error || ev.subtype) {
|
|
@@ -294,7 +351,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
294
351
|
// returned string for sentinel detection, and each activity is handed to
|
|
295
352
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
296
353
|
// off and keep the raw text passthrough + line sentinels.
|
|
297
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
354
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
298
355
|
return new Promise((resolve) => {
|
|
299
356
|
const rt = runtimeById(runtime);
|
|
300
357
|
if (!rt.args) {
|
|
@@ -425,7 +482,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
425
482
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
426
483
|
const onLine = (line) => {
|
|
427
484
|
if (!rt.parse)
|
|
428
|
-
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit });
|
|
485
|
+
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage });
|
|
429
486
|
const ev = rt.parse(line, cwd);
|
|
430
487
|
if (!ev) return;
|
|
431
488
|
// The conversation id, when the runtime announces one (codex's
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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).
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
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/bin/lib/work.mjs
CHANGED
|
@@ -4885,6 +4885,21 @@ export function createWorkManager({
|
|
|
4885
4885
|
|
|
4886
4886
|
let out = '';
|
|
4887
4887
|
let child = null;
|
|
4888
|
+
/**
|
|
4889
|
+
* WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
|
|
4890
|
+
*
|
|
4891
|
+
* Held across the whole turn so every settle below can carry it — a turn
|
|
4892
|
+
* that hit a limit, produced no parseable outcome or delivered properly
|
|
4893
|
+
* all spent real tokens, and a readout that only charged the happy path
|
|
4894
|
+
* would under-report the runs somebody opens the number to understand.
|
|
4895
|
+
*
|
|
4896
|
+
* The three settles it is spread into are the ones that follow a CLI
|
|
4897
|
+
* actually running. The pre-spawn refusals above (a bad place, a missing
|
|
4898
|
+
* card, the begun-guard) and the teardown sweep below deliberately do NOT
|
|
4899
|
+
* take it: nothing ran there, and `usage` stays null anyway, which is
|
|
4900
|
+
* what makes `...(usage ? … : {})` the whole guard.
|
|
4901
|
+
*/
|
|
4902
|
+
let usage = null;
|
|
4888
4903
|
try {
|
|
4889
4904
|
out = await runTurn({
|
|
4890
4905
|
prompt:
|
|
@@ -4944,6 +4959,13 @@ export function createWorkManager({
|
|
|
4944
4959
|
onToolEvent: (name, input) => {
|
|
4945
4960
|
trace.tool(toolEventOf(name, input, wt, envScrub));
|
|
4946
4961
|
},
|
|
4962
|
+
// The CLI's own per-turn token counts, off the `result` event. One
|
|
4963
|
+
// result per turn, so this is a set and not an accumulate — the
|
|
4964
|
+
// adding-up happens SERVER-side, behind the settle's idempotent win,
|
|
4965
|
+
// because a retried settle must not charge the same turn twice.
|
|
4966
|
+
onUsage: (u) => {
|
|
4967
|
+
usage = u;
|
|
4968
|
+
},
|
|
4947
4969
|
onSpawn: (ch) => {
|
|
4948
4970
|
child = ch;
|
|
4949
4971
|
// The AGENT it serves — what the machine snapshot charges this
|
|
@@ -5015,7 +5037,14 @@ export function createWorkManager({
|
|
|
5015
5037
|
// limit means all of them have. The turn itself is reported as
|
|
5016
5038
|
// `nothing` — it did not deliver and it did not ask.
|
|
5017
5039
|
await postAgentParked(limit);
|
|
5018
|
-
await postAgentTurn({
|
|
5040
|
+
await postAgentTurn({
|
|
5041
|
+
turnId,
|
|
5042
|
+
outcome: 'nothing',
|
|
5043
|
+
answer: limit,
|
|
5044
|
+
...(usage ? { usage } : {}),
|
|
5045
|
+
branch,
|
|
5046
|
+
worktree: wt,
|
|
5047
|
+
});
|
|
5019
5048
|
return;
|
|
5020
5049
|
}
|
|
5021
5050
|
|
|
@@ -5029,6 +5058,7 @@ export function createWorkManager({
|
|
|
5029
5058
|
? envScrub(out).slice(-1500)
|
|
5030
5059
|
: 'the turn produced no output on the machine — its CLI may be signed out',
|
|
5031
5060
|
...(commits.length ? { commits } : {}),
|
|
5061
|
+
...(usage ? { usage } : {}),
|
|
5032
5062
|
branch,
|
|
5033
5063
|
worktree: wt,
|
|
5034
5064
|
});
|
|
@@ -5040,6 +5070,7 @@ export function createWorkManager({
|
|
|
5040
5070
|
answer: envScrub(res.answer ?? '').slice(0, 8000),
|
|
5041
5071
|
...(commits.length ? { commits } : {}),
|
|
5042
5072
|
...(res.raised?.length ? { raised: res.raised } : {}),
|
|
5073
|
+
...(usage ? { usage } : {}),
|
|
5043
5074
|
branch,
|
|
5044
5075
|
worktree: wt,
|
|
5045
5076
|
});
|
|
@@ -5450,6 +5481,17 @@ export function createWorkManager({
|
|
|
5450
5481
|
* and is retried ONCE, because unlike a trace batch this body cost a whole
|
|
5451
5482
|
* model call and losing it to a blip means the operator paid for a label
|
|
5452
5483
|
* nobody ever sees.
|
|
5484
|
+
*
|
|
5485
|
+
* THE RETRY IS NOT A GUARANTEE THAT THE FIRST ATTEMPT FAILED, and since the
|
|
5486
|
+
* body started carrying `usage` (0.90.0) that matters. The `catch` below
|
|
5487
|
+
* takes the 30-second `AbortSignal` with everything else, so a server that
|
|
5488
|
+
* was merely SLOW — and committed — receives this identical body twice. The
|
|
5489
|
+
* store was always idempotent; the token counts riding it were not, and a
|
|
5490
|
+
* timed-out-but-committed post added them twice. The SERVER closes that: its
|
|
5491
|
+
* write refuses a body whose text already sits on the row, so a re-send
|
|
5492
|
+
* stores the same reading and charges nothing for it. Nothing here needs to
|
|
5493
|
+
* change, and nothing here may start assuming a thrown fetch means the
|
|
5494
|
+
* server never saw this.
|
|
5453
5495
|
*/
|
|
5454
5496
|
const postPre = async (body) => {
|
|
5455
5497
|
try {
|
|
@@ -5552,6 +5594,21 @@ export function createWorkManager({
|
|
|
5552
5594
|
let timer = null;
|
|
5553
5595
|
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
5554
5596
|
let wedged = false;
|
|
5597
|
+
/**
|
|
5598
|
+
* WHAT THE READING SPENT — the agent-turn lane's own relay, on the one
|
|
5599
|
+
* other lane that charges an AGENT (2026-09-19).
|
|
5600
|
+
*
|
|
5601
|
+
* It rides the agent and never a card link: a pre-review belongs to no card
|
|
5602
|
+
* (the same reason `workChildren` gets a null task id below), so the four
|
|
5603
|
+
* counters it adds to are the container's alone. That is also why an
|
|
5604
|
+
* agent's links can never sum to its total, and the schema says so.
|
|
5605
|
+
*
|
|
5606
|
+
* A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
|
|
5607
|
+
* nothing either — which under-reports a turn that really did spend. That
|
|
5608
|
+
* is the honest direction: there is no row to put it on, and inventing a
|
|
5609
|
+
* settle to carry a number would be a post whose only content is a bill.
|
|
5610
|
+
*/
|
|
5611
|
+
let usage = null;
|
|
5555
5612
|
/**
|
|
5556
5613
|
* THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
|
|
5557
5614
|
* to DELETE the transcript it leaves behind (review, 2026-09-17).
|
|
@@ -5612,6 +5669,11 @@ export function createWorkManager({
|
|
|
5612
5669
|
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
5613
5670
|
preSession = i.sessionId.trim();
|
|
5614
5671
|
},
|
|
5672
|
+
// The CLI's own count for this reading. Set, never accumulated: one
|
|
5673
|
+
// turn is one `result` event, and the adding-up is the server's.
|
|
5674
|
+
onUsage: (u) => {
|
|
5675
|
+
usage = u;
|
|
5676
|
+
},
|
|
5615
5677
|
onSpawn: (ch) => {
|
|
5616
5678
|
child = ch;
|
|
5617
5679
|
// No task id: a pre-review belongs to no card, and the machine
|
|
@@ -5710,6 +5772,7 @@ export function createWorkManager({
|
|
|
5710
5772
|
const body = {
|
|
5711
5773
|
agentId,
|
|
5712
5774
|
...(headSha ? { headSha } : {}),
|
|
5775
|
+
...(usage ? { usage } : {}),
|
|
5713
5776
|
cards: result.cards.map((cd) => ({
|
|
5714
5777
|
taskId: cd.taskId,
|
|
5715
5778
|
verdict: cd.verdict,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.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": {
|