flowviant 0.87.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.
- package/bin/lib/admission.mjs +110 -4
- package/bin/lib/agentCards.mjs +122 -0
- package/bin/lib/agentPlan.mjs +103 -28
- package/bin/lib/config.mjs +56 -13
- package/bin/lib/fleet.mjs +43 -0
- package/bin/lib/prompts.mjs +136 -1
- package/bin/lib/work.mjs +405 -10
- 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
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CARD SPECS AN AGENT WAS ACTUALLY GIVEN, kept on the box that gave them
|
|
3
|
+
* (2026-09-16).
|
|
4
|
+
*
|
|
5
|
+
* The AI pre-review reads a branch at review entry and has to answer "does this
|
|
6
|
+
* diff do what each card asked for" — which needs the cards. Nothing on the
|
|
7
|
+
* machine holds them: an agent turn is fed ONE card at a time, the server types
|
|
8
|
+
* the next when the previous lands, and the daemon composes the prompt and
|
|
9
|
+
* forgets it. By the time the queue empties, the only trace of card one on this
|
|
10
|
+
* disk is whatever its commits happen to say about themselves, and a commit
|
|
11
|
+
* message is a CLAIM about the work rather than the work's specification.
|
|
12
|
+
*
|
|
13
|
+
* So each turn appends the spec it typed. It is the daemon's own text — the same
|
|
14
|
+
* `AGENT_TASK_SPEC` block the agent read, so the reviewer reads what the agent
|
|
15
|
+
* read rather than a second rendering of the card that can drift from it.
|
|
16
|
+
*
|
|
17
|
+
* ── WHERE IT LIVES, AND WHY THAT IS THE WHOLE LIFECYCLE ──
|
|
18
|
+
*
|
|
19
|
+
* The worktree's PRIVATE git dir (`sessionMetaPath`), scoped by agent id. Three
|
|
20
|
+
* properties come free with that choice and none of them needs code:
|
|
21
|
+
*
|
|
22
|
+
* · it is INVISIBLE to `git status`, so a stash can never make a worktree
|
|
23
|
+
* dirty — which would refuse a ship, the exact trap a marker file in the
|
|
24
|
+
* working tree fell into;
|
|
25
|
+
* · it DIES with `git worktree remove`, so retiring an agent's worktree
|
|
26
|
+
* retires its stash. There is no sweep to write and none to forget;
|
|
27
|
+
* · it is per-BOX by construction, which is the honest answer to a machine
|
|
28
|
+
* handover: a box that adopted an agent mid-run holds only the prompts IT
|
|
29
|
+
* typed. The reviewer prompt SAYS how many it is missing rather than
|
|
30
|
+
* inventing the specs it does not have.
|
|
31
|
+
*
|
|
32
|
+
* ── IT IS A STASH, NOT A LEDGER ──
|
|
33
|
+
*
|
|
34
|
+
* Nothing reads it but the precheck, nothing is decided by it, and losing it
|
|
35
|
+
* costs one label nobody was promised. Every failure here is swallowed for that
|
|
36
|
+
* reason: a turn must never fail because a note about it could not be written.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How many card specs one agent may accumulate.
|
|
43
|
+
*
|
|
44
|
+
* A bound on a MACHINE — an agent's queue is a handful of cards, and this exists
|
|
45
|
+
* so a pathological agent (a card re-delivered fifty times, an agent grown past
|
|
46
|
+
* its budget) cannot turn a prompt into a file read. The NEWEST are kept, which
|
|
47
|
+
* is the same tail-is-what-matters rule the trace keeps.
|
|
48
|
+
*/
|
|
49
|
+
export const MAX_STASHED_CARDS = 40;
|
|
50
|
+
/** The most one spec may contribute. A brief is written by whoever filed the
|
|
51
|
+
* card and the server caps it, but this file is composed into a prompt and a
|
|
52
|
+
* bound it owns is a bound that cannot be argued away upstream. */
|
|
53
|
+
export const MAX_SPEC_CHARS = 8_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write down the spec this turn is about to hand the agent.
|
|
57
|
+
*
|
|
58
|
+
* ONE JSON OBJECT PER LINE, APPENDED. Append rather than rewrite because two
|
|
59
|
+
* turns of one agent never run at once (an agent's place is taken as a WRITER)
|
|
60
|
+
* but a crash between read and write of a whole-file rewrite would lose every
|
|
61
|
+
* earlier card — and because an append is atomic enough at this size that a
|
|
62
|
+
* half-written line is the only damage a kill can do, which the reader drops.
|
|
63
|
+
*
|
|
64
|
+
* A re-delivered card appends a SECOND line for the same id; the reader keeps
|
|
65
|
+
* the last, because that is the spec the agent most recently worked from.
|
|
66
|
+
*/
|
|
67
|
+
export function stashCard(path, taskId, spec) {
|
|
68
|
+
if (!path) return false;
|
|
69
|
+
const id = String(taskId ?? '').trim();
|
|
70
|
+
const text = String(spec ?? '');
|
|
71
|
+
if (!id || !text.trim()) return false;
|
|
72
|
+
try {
|
|
73
|
+
appendFileSync(
|
|
74
|
+
path,
|
|
75
|
+
JSON.stringify({ taskId: id.slice(0, 64), prompt: text.slice(0, MAX_SPEC_CHARS) }) + '\n'
|
|
76
|
+
);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
// A stash that could not be written costs the precheck one card's spec,
|
|
80
|
+
// which it will say it could not check rather than guess at.
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What this box holds, newest spec per card, in the order the cards were worked.
|
|
87
|
+
*
|
|
88
|
+
* MALFORMED LINES ARE DROPPED ALONE — the boundary rule this repo states for
|
|
89
|
+
* every relayed list: one truncated line (a daemon killed mid-append) must not
|
|
90
|
+
* throw away the thirty-nine good specs beside it.
|
|
91
|
+
*/
|
|
92
|
+
export function readStash(path) {
|
|
93
|
+
if (!path) return [];
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = readFileSync(path, 'utf8');
|
|
97
|
+
} catch {
|
|
98
|
+
return []; // no file: this agent has run no card turn on this box
|
|
99
|
+
}
|
|
100
|
+
/** taskId → spec. A Map, so the LAST write per card wins while the insertion
|
|
101
|
+
* order stays the order the cards were first handed out — which is the order
|
|
102
|
+
* the branch was built in, and the order a reviewer reads them in. */
|
|
103
|
+
const byId = new Map();
|
|
104
|
+
for (const line of raw.split('\n')) {
|
|
105
|
+
if (!line.trim()) continue;
|
|
106
|
+
let v;
|
|
107
|
+
try {
|
|
108
|
+
v = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!v || typeof v !== 'object') continue;
|
|
113
|
+
const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
|
|
114
|
+
const prompt = typeof v.prompt === 'string' ? v.prompt : '';
|
|
115
|
+
if (!taskId || !prompt.trim()) continue;
|
|
116
|
+
byId.set(taskId, prompt.slice(0, MAX_SPEC_CHARS));
|
|
117
|
+
}
|
|
118
|
+
const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
|
|
119
|
+
// The NEWEST cards when there are too many — a reviewer reading a grown
|
|
120
|
+
// agent's branch is reading the work at its end.
|
|
121
|
+
return all.slice(-MAX_STASHED_CARDS);
|
|
122
|
+
}
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* READING A
|
|
2
|
+
* READING A MODEL'S ANSWER, in the agent lane's three shapes: a planner's
|
|
3
|
+
* PROPOSAL, an agent's own TURN RESULT, and (2026-09-16) the AI pre-review's
|
|
4
|
+
* TRIAGE.
|
|
3
5
|
*
|
|
4
6
|
* The scratch agent behind a Deploy press is asked for one JSON object and
|
|
5
7
|
* nothing else. This is what turns its final message into a proposal, and it is
|
|
@@ -31,38 +33,50 @@ const MAX_NAME = 80;
|
|
|
31
33
|
const MAX_NOTE = 1000;
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* Find the
|
|
36
|
+
* Find the objects.
|
|
35
37
|
*
|
|
36
38
|
* A fenced block first, because that is what was asked for. Otherwise every `{`
|
|
37
39
|
* in the text is tried as a start, and its BALANCED end is found by counting
|
|
38
|
-
* braces while skipping string literals —
|
|
39
|
-
*
|
|
40
|
+
* braces while skipping string literals — each caller then takes the first
|
|
41
|
+
* candidate whose SHAPE is the one it asked for.
|
|
40
42
|
*
|
|
41
43
|
* The obvious cheap version — first `{` to last `}` — is wrong in a way a test
|
|
42
44
|
* caught: a planner that writes "I looked at {the auth module} first" before its
|
|
43
45
|
* JSON produces a span starting at the wrong brace, and the whole plan is lost
|
|
44
46
|
* to a sentence. Scanning candidates costs nothing at this size and cannot be
|
|
45
47
|
* defeated by prose.
|
|
48
|
+
*
|
|
49
|
+
* ONE SCANNER FOR ALL THREE READERS in this file (2026-09-16). It was written
|
|
50
|
+
* twice — once here and once inline in `parseTurnResult` — and a third copy was
|
|
51
|
+
* about to be written for the precheck. A brace scanner that skips string
|
|
52
|
+
* literals is exactly the kind of thing where two copies quietly stop agreeing
|
|
53
|
+
* about escapes and nobody notices, because the disagreement only shows up on a
|
|
54
|
+
* card title with a quote in it.
|
|
46
55
|
*/
|
|
47
|
-
function
|
|
56
|
+
function candidateObjects(raw) {
|
|
48
57
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
49
|
-
const
|
|
50
|
-
if (fenced)
|
|
58
|
+
const bodies = [];
|
|
59
|
+
if (fenced) bodies.push(fenced[1]);
|
|
51
60
|
for (let i = 0; i < raw.length; i++) {
|
|
52
61
|
if (raw[i] !== '{') continue;
|
|
53
62
|
const end = balanced(raw, i);
|
|
54
|
-
if (end > i)
|
|
63
|
+
if (end > i) bodies.push(raw.slice(i, end + 1));
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const body of bodies) {
|
|
57
67
|
if (!body.trim()) continue;
|
|
58
68
|
try {
|
|
59
69
|
const v = JSON.parse(body);
|
|
60
|
-
if (v && typeof v === 'object'
|
|
70
|
+
if (v && typeof v === 'object') out.push(v);
|
|
61
71
|
} catch {
|
|
62
72
|
/* the next candidate may be the object */
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
|
-
return
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extract(raw) {
|
|
79
|
+
return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
/** The index of the `}` that closes the `{` at `from`, or -1. Skips string
|
|
@@ -158,23 +172,7 @@ export function parseProposal(text) {
|
|
|
158
172
|
* up here rather than being read as success.
|
|
159
173
|
*/
|
|
160
174
|
export function parseTurnResult(text) {
|
|
161
|
-
const
|
|
162
|
-
const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
163
|
-
const candidates = [];
|
|
164
|
-
if (fenced) candidates.push(fenced[1]);
|
|
165
|
-
for (let i = 0; i < parsedRaw.length; i++) {
|
|
166
|
-
if (parsedRaw[i] !== '{') continue;
|
|
167
|
-
const end = balanced(parsedRaw, i);
|
|
168
|
-
if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
|
|
169
|
-
}
|
|
170
|
-
for (const body of candidates) {
|
|
171
|
-
let v;
|
|
172
|
-
try {
|
|
173
|
-
v = JSON.parse(body);
|
|
174
|
-
} catch {
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
if (!v || typeof v !== 'object') continue;
|
|
175
|
+
for (const v of candidateObjects(String(text ?? ''))) {
|
|
178
176
|
if (v.status === 'blocked') {
|
|
179
177
|
const question = typeof v.question === 'string' ? v.question.trim() : '';
|
|
180
178
|
// A "blocked" with no question is not an answer anybody can act on — it
|
|
@@ -203,3 +201,80 @@ export function parseTurnResult(text) {
|
|
|
203
201
|
}
|
|
204
202
|
return null;
|
|
205
203
|
}
|
|
204
|
+
|
|
205
|
+
/** The precheck's own bounds — mirrored at the server boundary, which caps
|
|
206
|
+
* again. A note is one sentence of triage and `overall` is one paragraph; the
|
|
207
|
+
* numbers are the ones SYSTEM_PRECHECK asks for, stated here so a model that
|
|
208
|
+
* ignores them cannot make the row bigger than the surface can render. */
|
|
209
|
+
const MAX_PRECHECK_CARDS = 60;
|
|
210
|
+
const MAX_PRECHECK_NOTE = 400;
|
|
211
|
+
const MAX_PRECHECK_OVERALL = 1200;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* READING THE AI PRE-REVIEW's ANSWER (2026-09-16).
|
|
215
|
+
*
|
|
216
|
+
* Same law as its two neighbours — LENIENT ON PACKAGING, STRICT ON SHAPE — and
|
|
217
|
+
* here the strict half has a sharper consequence than usual: this text is about
|
|
218
|
+
* to be rendered on the surface where somebody decides whether a branch reaches
|
|
219
|
+
* main. A half-read answer is a plausible-looking triage nobody wrote.
|
|
220
|
+
*
|
|
221
|
+
* NULL IS THE SAFE ANSWER AND THE COMMON ONE. A precheck that came back
|
|
222
|
+
* unparseable posts NOTHING, and the absence renders nothing: the human's review
|
|
223
|
+
* is exactly what it was before this feature existed. That is the whole reason
|
|
224
|
+
* this may be strict where `parseProposal` cannot be — a lost proposal wastes a
|
|
225
|
+
* press somebody made, a lost precheck costs a label nobody was promised.
|
|
226
|
+
*
|
|
227
|
+
* ONE ENTRY PER CARD, and the FIRST one wins: a model that judges a card twice
|
|
228
|
+
* has contradicted itself, and rendering two notes on one card face would ask
|
|
229
|
+
* the reviewer to arbitrate between them. An unknown verdict word is dropped
|
|
230
|
+
* rather than coerced — `ok` is a claim ("I looked and found nothing"), and
|
|
231
|
+
* guessing it from a word nobody listed would be the parser making that claim.
|
|
232
|
+
*
|
|
233
|
+
* `scrub` RIDES IN, AND IT RUNS BEFORE EVERY CUT (review, 2026-09-17).
|
|
234
|
+
*
|
|
235
|
+
* The caller used to scrub afterwards — `envScrub(cd.note).slice(0, 400)` over
|
|
236
|
+
* a note this function had ALREADY cut to 400. `scrub` replaces EXACT full
|
|
237
|
+
* values, so a credential straddling the cut arrived here pre-severed, matched
|
|
238
|
+
* nothing, and its surviving prefix was stored and rendered to every member of
|
|
239
|
+
* the project. That is byte-for-byte the bug `runCheck`'s output lane records
|
|
240
|
+
* learning the expensive way, and the reviewer this parses reads a worktree
|
|
241
|
+
* holding the project's materialized dev secrets — a note quoting a `.env`
|
|
242
|
+
* line is the ordinary way to reach it. The fix is `toolEventOf`'s: the scrub
|
|
243
|
+
* rides INTO the builder and runs over the whole field, before the cap.
|
|
244
|
+
*
|
|
245
|
+
* DEFAULTED TO IDENTITY so the parser stays testable on its own, and so a
|
|
246
|
+
* caller that forgets loses redaction rather than the whole reading — but the
|
|
247
|
+
* ONE production caller passes `envScrub`, and `work.test.mjs` pins the order.
|
|
248
|
+
*/
|
|
249
|
+
export function parsePrecheck(text, scrub = (s) => s) {
|
|
250
|
+
const parsed = candidateObjects(String(text ?? '')).find((v) => Array.isArray(v.cards));
|
|
251
|
+
if (!parsed) return null;
|
|
252
|
+
|
|
253
|
+
const cards = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
for (const c of parsed.cards.slice(0, MAX_PRECHECK_CARDS)) {
|
|
256
|
+
if (!c || typeof c !== 'object') continue;
|
|
257
|
+
const taskId = typeof c.taskId === 'string' ? c.taskId.trim().slice(0, 64) : '';
|
|
258
|
+
if (!taskId || seen.has(taskId)) continue;
|
|
259
|
+
if (c.verdict !== 'ok' && c.verdict !== 'concerns') continue;
|
|
260
|
+
seen.add(taskId);
|
|
261
|
+
// SCRUB, THEN CUT — see the docblock. The whole field is in hand here, so an
|
|
262
|
+
// exact-value match still finds a secret that spans the cap.
|
|
263
|
+
const note =
|
|
264
|
+
typeof c.note === 'string' ? scrub(c.note.trim()).slice(0, MAX_PRECHECK_NOTE) : '';
|
|
265
|
+
cards.push({ taskId, verdict: c.verdict, ...(note ? { note } : {}) });
|
|
266
|
+
}
|
|
267
|
+
const overall =
|
|
268
|
+
typeof parsed.overall === 'string'
|
|
269
|
+
? scrub(parsed.overall.trim()).slice(0, MAX_PRECHECK_OVERALL)
|
|
270
|
+
: '';
|
|
271
|
+
/**
|
|
272
|
+
* AN ANSWER THAT SAYS NOTHING IS NOT AN ANSWER. No readable card verdict and
|
|
273
|
+
* no overall means the model produced the right punctuation and no content —
|
|
274
|
+
* posting that would put an empty "Claude's pre-review" heading on the deck,
|
|
275
|
+
* which reads as a feature that ran and found the branch unremarkable. It did
|
|
276
|
+
* not run.
|
|
277
|
+
*/
|
|
278
|
+
if (cards.length === 0 && !overall) return null;
|
|
279
|
+
return { cards, ...(overall ? { overall } : {}) };
|
|
280
|
+
}
|
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/prompts.mjs
CHANGED
|
@@ -830,13 +830,25 @@ const safeName = (n) =>
|
|
|
830
830
|
.trim()
|
|
831
831
|
.slice(0, 60) || 'agent';
|
|
832
832
|
|
|
833
|
-
|
|
833
|
+
/**
|
|
834
|
+
* A CARD'S SPEC, WRITTEN DOWN ONCE.
|
|
835
|
+
*
|
|
836
|
+
* EXPORTED (2026-09-16) because a second reader now needs the identical text:
|
|
837
|
+
* the AI pre-review is composed from the card specs the daemon STASHED as it
|
|
838
|
+
* typed each turn's prompt, and the whole claim of that surface is that the
|
|
839
|
+
* reviewer read what the agent read. Two builders for one thing is two
|
|
840
|
+
* renderings of a card that can drift — and the drift would be invisible,
|
|
841
|
+
* because nobody reads both prompts side by side.
|
|
842
|
+
*/
|
|
843
|
+
export const AGENT_TASK_SPEC = (task) =>
|
|
834
844
|
`id: ${task?.id ?? ''}\n` +
|
|
835
845
|
`title: ${task?.title ?? ''}\n` +
|
|
836
846
|
(task?.brief ? `\nbrief:\n${task.brief}\n` : '') +
|
|
837
847
|
(task?.criteria?.length ? `\ndone when:\n${task.criteria.map((c) => `- ${c}`).join('\n')}\n` : '') +
|
|
838
848
|
(task?.anchors?.length ? `\nthis card owns:\n${task.anchors.map((a) => `- ${a}`).join('\n')}\n` : '');
|
|
839
849
|
|
|
850
|
+
const taskBlock = AGENT_TASK_SPEC;
|
|
851
|
+
|
|
840
852
|
/**
|
|
841
853
|
* A PERSON SPOKE TO THE AGENT — usually the answer to its own question.
|
|
842
854
|
*
|
|
@@ -852,3 +864,126 @@ export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, pos
|
|
|
852
864
|
`${fence('WHAT THEY SAID', message)}\n\n` +
|
|
853
865
|
(task ? `${fence('THE CARD YOU ARE ON', taskBlock(task))}\n\n` : '') +
|
|
854
866
|
`Carry on, and end with the JSON object as usual.`;
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* THE AI PRE-REVIEW — a FRESH Claude reads the branch before the human does
|
|
870
|
+
* (2026-09-16).
|
|
871
|
+
*
|
|
872
|
+
* The owner asked for it in these words: "before having the user manually check,
|
|
873
|
+
* can we have the daemon … spawn an agent to review the work so basically we get
|
|
874
|
+
* an ai to look at the review before a human looks at it for a double check."
|
|
875
|
+
*
|
|
876
|
+
* ── FRESH EYES, AND THAT IS THE ENTIRE DESIGN ──
|
|
877
|
+
*
|
|
878
|
+
* This is NOT the agent's own conversation asked to check itself. An agent that
|
|
879
|
+
* has spent four turns arguing itself into a design defends that design; asked
|
|
880
|
+
* whether its work meets the card, it answers from the same context that
|
|
881
|
+
* produced the work and finds it good. So the precheck is a NEW `claude -p` with
|
|
882
|
+
* no resumed conversation, standing in the agent's worktree because it needs the
|
|
883
|
+
* code and the diff, under the READ-ONLY profile the scratch planner and the
|
|
884
|
+
* capture chat already run behind, with no MCP at all. It reads; it cannot
|
|
885
|
+
* write; it has no control plane to reach even if the repository it is reading
|
|
886
|
+
* tries to steer it.
|
|
887
|
+
*
|
|
888
|
+
* ── IT LABELS AND NEVER BLOCKS ──
|
|
889
|
+
*
|
|
890
|
+
* The project's own check states this law and this obeys it identically:
|
|
891
|
+
* Approve, the verdicts and the ship quiz do not know this exists. A precheck
|
|
892
|
+
* that failed, timed out, or was never run posts NOTHING, and the absence
|
|
893
|
+
* renders nothing — ignorance never withholds a human's review, the same
|
|
894
|
+
* three-state rule every readout in this product keeps.
|
|
895
|
+
*
|
|
896
|
+
* ── AND IT IS ASKED FOR A TRIAGE, NOT A VERDICT ──
|
|
897
|
+
*
|
|
898
|
+
* The one thing a second reader can do that the first cannot is say WHERE TO
|
|
899
|
+
* LOOK FIRST. Asked to approve or reject, a model produces a confident judgment
|
|
900
|
+
* nobody asked it for and somebody will eventually treat as one. Asked what a
|
|
901
|
+
* reviewer should check first, it produces a list of places — which is useful
|
|
902
|
+
* whether it is right or wrong, because the human is about to look anyway.
|
|
903
|
+
*/
|
|
904
|
+
export const SYSTEM_PRECHECK = `You are a SECOND reviewer with fresh eyes, reading a branch an agent has just
|
|
905
|
+
finished. You did not write this code and you were not in the conversation that
|
|
906
|
+
produced it. That is the whole point of you.
|
|
907
|
+
|
|
908
|
+
A PERSON REVIEWS THIS NEXT, and your job is to tell them what to look at first.
|
|
909
|
+
You are not approving or rejecting anything: nothing you say gates the merge,
|
|
910
|
+
nothing you say is shown to the agent, and nobody is waiting on a decision from
|
|
911
|
+
you.
|
|
912
|
+
|
|
913
|
+
YOU ARE READ-ONLY. You cannot write, edit or create files, and you have no tools
|
|
914
|
+
beyond reading this repository. Do not try.
|
|
915
|
+
|
|
916
|
+
HOW TO READ IT:
|
|
917
|
+
|
|
918
|
+
1. READ THE DIFF. A commit message is a CLAIM about the work; the diff is the
|
|
919
|
+
work. Run the diff command you are given and read what actually changed
|
|
920
|
+
before you say anything about it.
|
|
921
|
+
2. VERIFY EACH CARD AGAINST ITS OWN ACCEPTANCE CRITERIA. For every card you are
|
|
922
|
+
given, decide FROM THE DIFF whether what was asked for is actually there.
|
|
923
|
+
"ok" means you looked and found nothing a reviewer needs warning about.
|
|
924
|
+
"concerns" means there is something specific you would want them to check
|
|
925
|
+
first.
|
|
926
|
+
3. BE SPECIFIC OR SAY NOTHING. "Looks reasonable" helps nobody. A concern names
|
|
927
|
+
a file, a function or a behaviour and says what about it worries you. If you
|
|
928
|
+
cannot point at something, the verdict is "ok".
|
|
929
|
+
4. NEVER INVENT. If a card's spec was not given to you, judge it from the diff
|
|
930
|
+
and the commits and SAY in your note what you could not check it against.
|
|
931
|
+
Never assume a file exists, a test passes, or a criterion was met because a
|
|
932
|
+
commit message says so.
|
|
933
|
+
5. YOU ARE NOT A STYLE GUIDE. Correctness, missing pieces, things the criteria
|
|
934
|
+
asked for that the diff does not show, changes that reach further than the
|
|
935
|
+
card did. Not formatting, not naming preferences, not the rewrite you would
|
|
936
|
+
have preferred.
|
|
937
|
+
|
|
938
|
+
END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
|
|
939
|
+
|
|
940
|
+
\`\`\`json
|
|
941
|
+
{
|
|
942
|
+
"cards": [
|
|
943
|
+
{ "taskId": "<card id, exactly as given>", "verdict": "ok", "note": "" },
|
|
944
|
+
{ "taskId": "<card id, exactly as given>", "verdict": "concerns", "note": "what a reviewer should look at first, and why" }
|
|
945
|
+
],
|
|
946
|
+
"overall": "what you would tell the reviewer before they start reading"
|
|
947
|
+
}
|
|
948
|
+
\`\`\`
|
|
949
|
+
|
|
950
|
+
One entry per card, AT MOST ONE note each, and use the card ids exactly as
|
|
951
|
+
given. A note is at most 400 characters and "overall" at most 1200: you are
|
|
952
|
+
writing the first paragraph of somebody's review, not the review.`;
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The precheck's turn.
|
|
956
|
+
*
|
|
957
|
+
* THE CARDS AND THE COMMITS ARE FENCED. A card's title, brief and criteria are
|
|
958
|
+
* written by whoever files cards in this project; a commit subject is written by
|
|
959
|
+
* a model that has just been editing files. Both are untrusted content this turn
|
|
960
|
+
* reads, and the instruction that matters — "read the diff and triage it" — is
|
|
961
|
+
* ours and sits outside the fence.
|
|
962
|
+
*
|
|
963
|
+
* `missingSpecs` IS MEASURED, NOT GUESSED. A box that adopted this agent
|
|
964
|
+
* mid-run (the machine moved, or an older turn ran elsewhere) holds only the
|
|
965
|
+
* prompts IT typed, so the stash can be short of what the branch carries. The
|
|
966
|
+
* count is the difference between the `Flowviant-Task:` trailers on the branch
|
|
967
|
+
* and the specs on this disk — so the reviewer is told what it could not read
|
|
968
|
+
* rather than being handed a silent gap, and never told a card exists that
|
|
969
|
+
* nothing measured.
|
|
970
|
+
*/
|
|
971
|
+
export const AGENT_PRECHECK_KICKOFF = ({
|
|
972
|
+
agentName,
|
|
973
|
+
cards,
|
|
974
|
+
missingSpecs = 0,
|
|
975
|
+
commits,
|
|
976
|
+
diffCommand,
|
|
977
|
+
}) =>
|
|
978
|
+
`The agent "${safeName(agentName)}" has finished its queue. A person is about to ` +
|
|
979
|
+
`review this branch; you are reading it first.\n\n` +
|
|
980
|
+
`${fence('THE CARDS IT WAS GIVEN', cards || '(none of this branch’s card specs are on this machine)')}\n\n` +
|
|
981
|
+
(missingSpecs > 0
|
|
982
|
+
? `${missingSpecs} earlier card${missingSpecs === 1 ? "'s spec is" : "s' specs are"} ` +
|
|
983
|
+
`not on this box — review ${missingSpecs === 1 ? 'it' : 'them'} from the diff and the ` +
|
|
984
|
+
`commits below, and say in your note what you could not check them against.\n\n`
|
|
985
|
+
: '') +
|
|
986
|
+
`${fence('THE COMMITS ON THIS BRANCH', commits || '(none)')}\n\n` +
|
|
987
|
+
`Read the diff yourself before you judge any of it:\n\n` +
|
|
988
|
+
` ${diffCommand}\n\n` +
|
|
989
|
+
`Then answer with the JSON object and nothing else.`;
|
package/bin/lib/work.mjs
CHANGED
|
@@ -52,7 +52,8 @@ import { listenersIn, measureListeners, listenersSupported } from './listeners.m
|
|
|
52
52
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
53
53
|
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
54
54
|
import { createPlaceLock } from './placeLock.mjs';
|
|
55
|
-
import { parseProposal, parseTurnResult } from './agentPlan.mjs';
|
|
55
|
+
import { parseProposal, parsePrecheck, parseTurnResult } from './agentPlan.mjs';
|
|
56
|
+
import { readStash, stashCard } from './agentCards.mjs';
|
|
56
57
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
57
58
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
58
59
|
import { openTunnel } from './preview.mjs';
|
|
@@ -68,8 +69,11 @@ import {
|
|
|
68
69
|
SYSTEM_PLAN,
|
|
69
70
|
AGENT_PLAN_KICKOFF,
|
|
70
71
|
SYSTEM_AGENT,
|
|
72
|
+
SYSTEM_PRECHECK,
|
|
71
73
|
AGENT_TASK_KICKOFF,
|
|
74
|
+
AGENT_TASK_SPEC,
|
|
72
75
|
AGENT_HUMAN_KICKOFF,
|
|
76
|
+
AGENT_PRECHECK_KICKOFF,
|
|
73
77
|
} from './prompts.mjs';
|
|
74
78
|
import {
|
|
75
79
|
materializeInto,
|
|
@@ -84,6 +88,7 @@ import {
|
|
|
84
88
|
pickRuntimeFor,
|
|
85
89
|
recordSkills,
|
|
86
90
|
toolEventOf,
|
|
91
|
+
removeProbeTranscript,
|
|
87
92
|
CLAUDE_TOOL_PROSE_KINDS,
|
|
88
93
|
RUNTIMES,
|
|
89
94
|
} from './runtimes.mjs';
|
|
@@ -222,6 +227,7 @@ export function createWorkManager({
|
|
|
222
227
|
const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
|
|
223
228
|
const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
|
|
224
229
|
const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
|
|
230
|
+
const AGENT_PRECHECK_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-precheck');
|
|
225
231
|
const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
|
|
226
232
|
const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
|
|
227
233
|
// What arrived on base, whichever road it took — observed after every beat
|
|
@@ -4810,6 +4816,34 @@ export function createWorkManager({
|
|
|
4810
4816
|
*/
|
|
4811
4817
|
const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
|
|
4812
4818
|
|
|
4819
|
+
/**
|
|
4820
|
+
* WRITE THE CARD DOWN BEFORE HANDING IT OVER — the material the AI
|
|
4821
|
+
* pre-review reads at review entry (see agentCards.mjs).
|
|
4822
|
+
*
|
|
4823
|
+
* HERE rather than at review entry because here is the ONLY moment this
|
|
4824
|
+
* machine holds the card at all: the server feeds an agent one card per
|
|
4825
|
+
* prompt and keeps no copy on this disk, so by the time the queue empties
|
|
4826
|
+
* card one exists locally as nothing but its own commit messages — which
|
|
4827
|
+
* are a claim about the work, not the specification it was judged against.
|
|
4828
|
+
*
|
|
4829
|
+
* BEFORE the CLI runs rather than after, so a turn that crashes still
|
|
4830
|
+
* leaves the spec behind: the card really was given to the agent, the
|
|
4831
|
+
* commits it made are on the branch, and a reviewer is entitled to read
|
|
4832
|
+
* what was asked for either way.
|
|
4833
|
+
*
|
|
4834
|
+
* ONE SPEC BUILDER (`AGENT_TASK_SPEC`) shared with the kickoff below, so
|
|
4835
|
+
* what the reviewer reads is byte-identical to what the agent read.
|
|
4836
|
+
* Failure is swallowed inside `stashCard`: a note about a turn may never
|
|
4837
|
+
* cost the turn.
|
|
4838
|
+
*/
|
|
4839
|
+
if (job.kind === 'task' && job.task) {
|
|
4840
|
+
stashCard(
|
|
4841
|
+
sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
|
|
4842
|
+
job.task.id,
|
|
4843
|
+
AGENT_TASK_SPEC(job.task)
|
|
4844
|
+
);
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4813
4847
|
/**
|
|
4814
4848
|
* THE WHOLE STREAM, not just its latest line — see trace.mjs.
|
|
4815
4849
|
*
|
|
@@ -5009,9 +5043,10 @@ export function createWorkManager({
|
|
|
5009
5043
|
branch,
|
|
5010
5044
|
worktree: wt,
|
|
5011
5045
|
});
|
|
5012
|
-
// The queue just emptied. Run the project's own check
|
|
5013
|
-
// worktree we are already standing in and still
|
|
5014
|
-
|
|
5046
|
+
// The queue just emptied. Run the project's own check and the AI
|
|
5047
|
+
// pre-review HERE, in the worktree we are already standing in and still
|
|
5048
|
+
// hold the lock on — see `runReviewEntry`.
|
|
5049
|
+
if (reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
|
|
5015
5050
|
});
|
|
5016
5051
|
/**
|
|
5017
5052
|
* …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
|
|
@@ -5102,8 +5137,8 @@ export function createWorkManager({
|
|
|
5102
5137
|
// body — never the CLI, which would spend the operator's quota again
|
|
5103
5138
|
// and write a second set of commits. The reply can still carry the one
|
|
5104
5139
|
// instruction a settle can (`review: true`, the queue just emptied),
|
|
5105
|
-
// so the project's check
|
|
5106
|
-
// lock the turn itself would have held.
|
|
5140
|
+
// so the project's check and the AI pre-review run from here too, under
|
|
5141
|
+
// the same writer lock the turn itself would have held.
|
|
5107
5142
|
agentTurns.add(id);
|
|
5108
5143
|
void (async () => {
|
|
5109
5144
|
const reply = await postAgentTurn(held.body);
|
|
@@ -5111,7 +5146,7 @@ export function createWorkManager({
|
|
|
5111
5146
|
const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
|
|
5112
5147
|
if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
|
|
5113
5148
|
await inPlace(place, place.startsWith('a-'), () =>
|
|
5114
|
-
|
|
5149
|
+
runReviewEntry(String(job.agentId || ''), wt, job.agentName)
|
|
5115
5150
|
);
|
|
5116
5151
|
}
|
|
5117
5152
|
})()
|
|
@@ -5361,6 +5396,363 @@ export function createWorkManager({
|
|
|
5361
5396
|
});
|
|
5362
5397
|
};
|
|
5363
5398
|
|
|
5399
|
+
// ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
|
|
5400
|
+
//
|
|
5401
|
+
// A FRESH Claude reads the branch before the human does. The owner asked for
|
|
5402
|
+
// it in these words: "before having the user manually check, can we have the
|
|
5403
|
+
// daemon … spawn an agent to review the work so basically we get an ai to look
|
|
5404
|
+
// at the review before a human looks at it for a double check."
|
|
5405
|
+
//
|
|
5406
|
+
// IT IS NOT THE AGENT CHECKING ITSELF. `runTurn` is called with no `resume`,
|
|
5407
|
+
// so there is no conversation to inherit: an agent that spent four turns
|
|
5408
|
+
// arguing itself into a design defends that design, and asked whether its work
|
|
5409
|
+
// meets the card it answers from the very context that produced the work. The
|
|
5410
|
+
// reviewer stands IN the agent's worktree because it needs the code and the
|
|
5411
|
+
// diff, under `readOnly` (CONSULT_PERM — Read, Grep, Glob and a few git reads)
|
|
5412
|
+
// with NO MCP passed at all, so there is no control plane on this turn even if
|
|
5413
|
+
// the repository it reads tries to steer it.
|
|
5414
|
+
//
|
|
5415
|
+
// IT LABELS AND NEVER BLOCKS — the check's own law, one function up. Approve,
|
|
5416
|
+
// the per-card verdicts and the ship quiz do not know this exists. Every exit
|
|
5417
|
+
// below POSTS NOTHING, and the server renders an absent precheck as nothing:
|
|
5418
|
+
// a failed, timed-out, skipped or unparseable read leaves the human's review
|
|
5419
|
+
// exactly as it was before this feature existed. Ignorance never withholds.
|
|
5420
|
+
//
|
|
5421
|
+
// NO VERSION FLOOR. This is a daemon→server report on a NEW endpoint, so an
|
|
5422
|
+
// older daemon simply never posts, and an older SERVER 404s — which `postPre`
|
|
5423
|
+
// treats as delivered-and-done for the agent-trace reason stated there.
|
|
5424
|
+
//
|
|
5425
|
+
// IT RIDES THE BEAT THE CHECK ALREADY OWNS (`runReviewEntry`), AFTER it: the
|
|
5426
|
+
// check is a local command and this is a model call, so the cheap answer lands
|
|
5427
|
+
// on the row first and a wedged reviewer cannot delay it.
|
|
5428
|
+
/**
|
|
5429
|
+
* FIVE MINUTES, and `runTurn` has no timer of its own.
|
|
5430
|
+
*
|
|
5431
|
+
* Half the planner's cap, because this turn is strictly smaller — it reads one
|
|
5432
|
+
* branch's diff and answers, where a planner reads a repository to decide
|
|
5433
|
+
* whether a batch of work collides. And it is HELD INSIDE THE PLACE WRITER
|
|
5434
|
+
* LOCK by the beat it rides, so every minute here is a minute the agent's next
|
|
5435
|
+
* turn (or its merge) is waiting: a generous cap on a label would be spending
|
|
5436
|
+
* the work's time on a note about the work.
|
|
5437
|
+
*/
|
|
5438
|
+
const PRECHECK_TIMEOUT_MS = 5 * 60_000;
|
|
5439
|
+
/** Commit subjects handed to the reviewer. A bound on the prompt, not on the
|
|
5440
|
+
* branch — the reviewer reads the diff itself, and the log is context. */
|
|
5441
|
+
const PRECHECK_LOG_LINES = 80;
|
|
5442
|
+
|
|
5443
|
+
/**
|
|
5444
|
+
* ONE PRE-REVIEW, POSTED.
|
|
5445
|
+
*
|
|
5446
|
+
* Resolves TRUE for a permanent refusal as well as a success, and that is
|
|
5447
|
+
* deliberate — the `postAgentTrace` rule, for the same reason: a server with
|
|
5448
|
+
* no such route 404s this body and will 404 every retry of it, so re-sending
|
|
5449
|
+
* would be a wedge wearing a retry's clothes. A NETWORK error resolves false
|
|
5450
|
+
* and is retried ONCE, because unlike a trace batch this body cost a whole
|
|
5451
|
+
* model call and losing it to a blip means the operator paid for a label
|
|
5452
|
+
* nobody ever sees.
|
|
5453
|
+
*/
|
|
5454
|
+
const postPre = async (body) => {
|
|
5455
|
+
try {
|
|
5456
|
+
const res = await fetch(AGENT_PRECHECK_URL, {
|
|
5457
|
+
method: 'POST',
|
|
5458
|
+
headers: {
|
|
5459
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
5460
|
+
'User-Agent': USER_AGENT,
|
|
5461
|
+
'Content-Type': 'application/json',
|
|
5462
|
+
},
|
|
5463
|
+
signal: AbortSignal.timeout(30_000),
|
|
5464
|
+
body: JSON.stringify(body),
|
|
5465
|
+
});
|
|
5466
|
+
return (
|
|
5467
|
+
res.ok ||
|
|
5468
|
+
(res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
5469
|
+
);
|
|
5470
|
+
} catch {
|
|
5471
|
+
return false; // a blip — worth one more attempt at a model call's answer
|
|
5472
|
+
}
|
|
5473
|
+
};
|
|
5474
|
+
|
|
5475
|
+
/**
|
|
5476
|
+
* THE BRANCH'S OWN COMMITS, subject + trailers, and the card ids they name.
|
|
5477
|
+
*
|
|
5478
|
+
* MEASURED, never asserted — the same reason `commitsBetween` exists. The
|
|
5479
|
+
* trailer ids are what let the prompt say "N earlier cards' specs are not on
|
|
5480
|
+
* this box" honestly: the difference between the cards this branch claims and
|
|
5481
|
+
* the specs this disk holds is a fact, and a box that adopted the agent
|
|
5482
|
+
* mid-run is exactly the case where it is non-zero.
|
|
5483
|
+
*
|
|
5484
|
+
* IT CANNOT THROW. `baseRef()` is free text an owner typed and may name no
|
|
5485
|
+
* ref at all; an unreadable range costs the reviewer its context, never the
|
|
5486
|
+
* review-entry beat it is standing in.
|
|
5487
|
+
*/
|
|
5488
|
+
const branchLog = (wt) => {
|
|
5489
|
+
let out;
|
|
5490
|
+
try {
|
|
5491
|
+
out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
|
|
5492
|
+
} catch {
|
|
5493
|
+
return { text: '', taskIds: [] };
|
|
5494
|
+
}
|
|
5495
|
+
if (typeof out !== 'string') return { text: '', taskIds: [] };
|
|
5496
|
+
const taskIds = new Set();
|
|
5497
|
+
for (const m of out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)) {
|
|
5498
|
+
taskIds.add(m[1].slice(0, 64));
|
|
5499
|
+
}
|
|
5500
|
+
const text = out
|
|
5501
|
+
.split('\n')
|
|
5502
|
+
.filter((l) => l.trim())
|
|
5503
|
+
.slice(0, PRECHECK_LOG_LINES)
|
|
5504
|
+
.join('\n');
|
|
5505
|
+
return { text, taskIds: [...taskIds] };
|
|
5506
|
+
};
|
|
5507
|
+
|
|
5508
|
+
const runPrecheck = async (agentId, wt, agentName) => {
|
|
5509
|
+
/**
|
|
5510
|
+
* WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
|
|
5511
|
+
* planner uses, and for the same reason: this prompt was written against
|
|
5512
|
+
* Claude, and a machine with no read-only-capable runtime simply does not
|
|
5513
|
+
* produce a precheck. Nothing is posted and nothing is said on the row.
|
|
5514
|
+
*/
|
|
5515
|
+
const rt = pickRuntimeFor('consult');
|
|
5516
|
+
if (!rt) return;
|
|
5517
|
+
/**
|
|
5518
|
+
* THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
|
|
5519
|
+
* asks it. `churn` and never `interactive`: nobody is watching this, and a
|
|
5520
|
+
* label is the first thing that should not be started on a box that is
|
|
5521
|
+
* struggling. Deferring here does NOT queue anything — there is no job and
|
|
5522
|
+
* no re-offer — so a precheck skipped under pressure is simply a precheck
|
|
5523
|
+
* that did not happen, which is what absence already means.
|
|
5524
|
+
*/
|
|
5525
|
+
const hold = admit('churn');
|
|
5526
|
+
if (hold) {
|
|
5527
|
+
note(`${c.cyan('pre-review')} ${c.dim(`— skipped: ${hold.reason}`)}`);
|
|
5528
|
+
return;
|
|
5529
|
+
}
|
|
5530
|
+
const releaseSlot = admit.reserve();
|
|
5531
|
+
|
|
5532
|
+
// THE HEAD THE READING BELONGS TO, taken BEFORE the turn — the
|
|
5533
|
+
// `checkFingerprint` shape, so a commit landing after this voids it rather
|
|
5534
|
+
// than letting an old reading label a branch it never saw. Optional: an
|
|
5535
|
+
// unreadable head costs the staleness comparison, not the precheck.
|
|
5536
|
+
let headSha = null;
|
|
5537
|
+
try {
|
|
5538
|
+
headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
|
|
5539
|
+
} catch {
|
|
5540
|
+
headSha = null;
|
|
5541
|
+
}
|
|
5542
|
+
|
|
5543
|
+
const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
|
|
5544
|
+
const log = branchLog(wt);
|
|
5545
|
+
/** Cards the BRANCH names that this box has no spec for. Measured, not
|
|
5546
|
+
* guessed — see agentCards.mjs on why a stash is per-box. */
|
|
5547
|
+
const held = new Set(stash.map((s) => s.taskId));
|
|
5548
|
+
const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
|
|
5549
|
+
|
|
5550
|
+
let out = '';
|
|
5551
|
+
let child = null;
|
|
5552
|
+
let timer = null;
|
|
5553
|
+
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
5554
|
+
let wedged = false;
|
|
5555
|
+
/**
|
|
5556
|
+
* THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
|
|
5557
|
+
* to DELETE the transcript it leaves behind (review, 2026-09-17).
|
|
5558
|
+
*
|
|
5559
|
+
* This is the first thing in the daemon to run a second `claude -p` inside
|
|
5560
|
+
* an AGENT's worktree, and the agent's own resume is `--continue`, which is
|
|
5561
|
+
* CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
|
|
5562
|
+
* ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
|
|
5563
|
+
* Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
|
|
5564
|
+
* makes the read-only stranger the newest conversation in that directory,
|
|
5565
|
+
* so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
|
|
5566
|
+
* a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
|
|
5567
|
+
* READ-ONLY" instead of its own four-turn context, under build permissions.
|
|
5568
|
+
* That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
|
|
5569
|
+
* arriving a third time by a third route.
|
|
5570
|
+
*
|
|
5571
|
+
* `removeProbeTranscript` is exported for precisely this and already serves
|
|
5572
|
+
* the skills probe and the dev-command resolver.
|
|
5573
|
+
*/
|
|
5574
|
+
let preSession = null;
|
|
5575
|
+
try {
|
|
5576
|
+
/**
|
|
5577
|
+
* THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
|
|
5578
|
+
* the kill — the shape the project check and the planner both keep. A
|
|
5579
|
+
* SIGKILLed process whose stdio a grandchild still holds can be slow to
|
|
5580
|
+
* emit `close`, or never emit it, and this promise is inside the place's
|
|
5581
|
+
* writer lock.
|
|
5582
|
+
*/
|
|
5583
|
+
let stopWaiting = () => {};
|
|
5584
|
+
const capped = new Promise((r) => {
|
|
5585
|
+
stopWaiting = r;
|
|
5586
|
+
});
|
|
5587
|
+
const turn = runTurn({
|
|
5588
|
+
prompt: AGENT_PRECHECK_KICKOFF({
|
|
5589
|
+
agentName,
|
|
5590
|
+
cards: stash.map((s) => s.prompt).join('\n---\n'),
|
|
5591
|
+
missingSpecs,
|
|
5592
|
+
commits: log.text,
|
|
5593
|
+
diffCommand: `git diff ${baseRef()}...HEAD`,
|
|
5594
|
+
}),
|
|
5595
|
+
system: SYSTEM_PRECHECK,
|
|
5596
|
+
// READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
|
|
5597
|
+
// passed empty, so there is no control plane on this turn at all.
|
|
5598
|
+
readOnly: true,
|
|
5599
|
+
cwd: wt,
|
|
5600
|
+
runtime: rt,
|
|
5601
|
+
// NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
|
|
5602
|
+
// agent grading its own homework out of its own context; this is a
|
|
5603
|
+
// stranger reading a diff.
|
|
5604
|
+
streamJson: true,
|
|
5605
|
+
answerFromResult: true,
|
|
5606
|
+
label: c.cyan('[pre-review]'),
|
|
5607
|
+
// The id the CLI reports at `system.init` — harvested off the stream
|
|
5608
|
+
// this turn already parses, no probe and no extra spawn. Held only so
|
|
5609
|
+
// the `finally` below can delete this turn's transcript; see
|
|
5610
|
+
// `preSession`.
|
|
5611
|
+
onInit: (i) => {
|
|
5612
|
+
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
5613
|
+
preSession = i.sessionId.trim();
|
|
5614
|
+
},
|
|
5615
|
+
onSpawn: (ch) => {
|
|
5616
|
+
child = ch;
|
|
5617
|
+
// No task id: a pre-review belongs to no card, and the machine
|
|
5618
|
+
// snapshot's per-task rows must not invent one. It still COUNTS
|
|
5619
|
+
// against the machine's ceiling — see liveTurnCount.
|
|
5620
|
+
workChildren.set(ch, null);
|
|
5621
|
+
releaseSlot();
|
|
5622
|
+
// ARMED AT THE SPAWN, not at entry: time spent getting here is not a
|
|
5623
|
+
// wedged CLI. The CHILD and never its group — a read-only turn starts
|
|
5624
|
+
// no server, so there is nothing behind it worth signalling and
|
|
5625
|
+
// everything to lose by signalling somebody else's.
|
|
5626
|
+
timer = setTimeout(() => {
|
|
5627
|
+
wedged = true;
|
|
5628
|
+
try {
|
|
5629
|
+
ch.kill('SIGKILL');
|
|
5630
|
+
} catch {
|
|
5631
|
+
/* already gone */
|
|
5632
|
+
}
|
|
5633
|
+
stopWaiting('');
|
|
5634
|
+
}, PRECHECK_TIMEOUT_MS);
|
|
5635
|
+
timer.unref?.();
|
|
5636
|
+
},
|
|
5637
|
+
});
|
|
5638
|
+
out = await Promise.race([turn, capped]);
|
|
5639
|
+
} catch {
|
|
5640
|
+
return; // a label may never fail the beat it rides
|
|
5641
|
+
} finally {
|
|
5642
|
+
if (timer) clearTimeout(timer);
|
|
5643
|
+
if (child) workChildren.delete(child);
|
|
5644
|
+
releaseSlot();
|
|
5645
|
+
/**
|
|
5646
|
+
* DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
|
|
5647
|
+
* killed, or thrown — because every one of them leaves the file behind
|
|
5648
|
+
* and the agent's `--continue` reads the newest one in the directory.
|
|
5649
|
+
*
|
|
5650
|
+
* AFTER the kill and ON A DELAY, the skills probe's own shape: the
|
|
5651
|
+
* transcript is the CHILD's file, so removing it while the child is still
|
|
5652
|
+
* dying races a recreate. Unref'd — it must not hold the process open.
|
|
5653
|
+
*/
|
|
5654
|
+
if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
// A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
|
|
5658
|
+
// waiting on an answer, so the honest record is that no pre-review exists —
|
|
5659
|
+
// the same silence a machine that never ran one leaves.
|
|
5660
|
+
if (wedged) {
|
|
5661
|
+
note(`${c.cyan('pre-review')} ${c.dim('— ran past five minutes and was stopped')}`);
|
|
5662
|
+
return;
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5665
|
+
/**
|
|
5666
|
+
* SCRUBBED ON THE WAY OUT, every string — AND SCRUBBED BEFORE IT IS CUT,
|
|
5667
|
+
* which is the order that matters and the reason `envScrub` rides INTO the
|
|
5668
|
+
* parser rather than being applied to what comes back out.
|
|
5669
|
+
*
|
|
5670
|
+
* This turn read the repository with `cat` and `git show` in a worktree
|
|
5671
|
+
* holding the project's materialized dev secrets, and its answer is about to
|
|
5672
|
+
* be stored and rendered to every member of the project. `scrub` replaces
|
|
5673
|
+
* EXACT full values, so a note capped first and scrubbed second hands the
|
|
5674
|
+
* scrub a credential already cut in half: it matches nothing and the
|
|
5675
|
+
* surviving prefix ships. The check's output lane learned exactly that the
|
|
5676
|
+
* expensive way, and this lane relearned it in review (2026-09-17) — see
|
|
5677
|
+
* `parsePrecheck`, which now caps only what it has already redacted.
|
|
5678
|
+
*/
|
|
5679
|
+
const result = parsePrecheck(out, envScrub);
|
|
5680
|
+
|
|
5681
|
+
/**
|
|
5682
|
+
* A QUOTA LIMIT SKIPS THE PRE-REVIEW AND PARKS NOTHING — AND A LIMIT IS
|
|
5683
|
+
* ONLY A LIMIT WHEN THE READING PRODUCED NOTHING.
|
|
5684
|
+
*
|
|
5685
|
+
* `limitLine` is a literal phrase match over the CLI's whole output, and
|
|
5686
|
+
* under `answerFromResult` that output IS the reviewer's answer — so a
|
|
5687
|
+
* pre-review OF rate-limiting code, or any triage that quotes the phrase,
|
|
5688
|
+
* read as a quota failure and threw a perfectly good reading away. The
|
|
5689
|
+
* agent-turn lane fixed this exact false positive once (`const limit = res
|
|
5690
|
+
* ? null : limitLine(out)`); gating on "nothing parsed" is what makes the
|
|
5691
|
+
* match mean what it says.
|
|
5692
|
+
*
|
|
5693
|
+
* And when it IS a limit, nothing parks. `postAgentParked` stops EVERY
|
|
5694
|
+
* agent on the project, because the CLI login is shared — the right answer
|
|
5695
|
+
* when the thing that hit the limit was somebody's actual work, the wrong
|
|
5696
|
+
* one here: parking a whole fleet because a LABEL could not be written
|
|
5697
|
+
* would let an optional readout take the product's primary lane down. The
|
|
5698
|
+
* branch is still reviewable; it just has no note on it.
|
|
5699
|
+
*/
|
|
5700
|
+
if (!result) {
|
|
5701
|
+
if (limitLine(out)) {
|
|
5702
|
+
note(`${c.cyan('pre-review')} ${c.dim('— skipped: the CLI reported a limit')}`);
|
|
5703
|
+
}
|
|
5704
|
+
// UNPARSEABLE POSTS NOTHING. A half-read triage is a plausible-looking
|
|
5705
|
+
// paragraph nobody wrote, rendered on the surface where somebody decides
|
|
5706
|
+
// whether a branch reaches main — see parsePrecheck.
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
const body = {
|
|
5711
|
+
agentId,
|
|
5712
|
+
...(headSha ? { headSha } : {}),
|
|
5713
|
+
cards: result.cards.map((cd) => ({
|
|
5714
|
+
taskId: cd.taskId,
|
|
5715
|
+
verdict: cd.verdict,
|
|
5716
|
+
...(cd.note ? { note: cd.note } : {}),
|
|
5717
|
+
})),
|
|
5718
|
+
...(result.overall ? { overall: result.overall } : {}),
|
|
5719
|
+
};
|
|
5720
|
+
// ONE RETRY, and only for a network error — see `postPre`. A permanent
|
|
5721
|
+
// refusal is an older server, and asking it again changes nothing.
|
|
5722
|
+
if (!(await postPre(body))) await postPre(body);
|
|
5723
|
+
};
|
|
5724
|
+
|
|
5725
|
+
/**
|
|
5726
|
+
* REVIEW ENTRY — everything this machine does the moment an agent's queue
|
|
5727
|
+
* empties, in one place.
|
|
5728
|
+
*
|
|
5729
|
+
* It exists so the two readings cannot drift apart at the three call sites
|
|
5730
|
+
* that own this beat (the settle reply, the held body's re-POST, and the
|
|
5731
|
+
* stale-merge re-read after base is folded in). Each of those used to call
|
|
5732
|
+
* `runCheck` directly; a second thing to run at the same moment is a second
|
|
5733
|
+
* thing three call sites can forget.
|
|
5734
|
+
*
|
|
5735
|
+
* THE CHECK FIRST, ALWAYS. It is a local command whose answer the board wants
|
|
5736
|
+
* on the row immediately; the pre-review is a model call that may take
|
|
5737
|
+
* minutes. Ordering them the other way would put a label behind a label.
|
|
5738
|
+
*
|
|
5739
|
+
* NEITHER MAY THROW PAST THIS POINT. Both are optional readouts and both run
|
|
5740
|
+
* INSIDE the place's writer lock on a path whose callers settle real work —
|
|
5741
|
+
* `runAgentMerge` in particular reports a claimed merge after this returns.
|
|
5742
|
+
*/
|
|
5743
|
+
const runReviewEntry = async (agentId, wt, agentName) => {
|
|
5744
|
+
try {
|
|
5745
|
+
await runCheck(agentId, wt);
|
|
5746
|
+
} catch {
|
|
5747
|
+
/* the row keeps its previous check answer, which is null the first time */
|
|
5748
|
+
}
|
|
5749
|
+
try {
|
|
5750
|
+
await runPrecheck(agentId, wt, agentName);
|
|
5751
|
+
} catch {
|
|
5752
|
+
/* no pre-review is posted, and absence renders nothing */
|
|
5753
|
+
}
|
|
5754
|
+
};
|
|
5755
|
+
|
|
5364
5756
|
// ── THE MERGE ──────────────────────────────────────────────────────────────
|
|
5365
5757
|
//
|
|
5366
5758
|
// LEASED, because two `git merge --no-ff` and two pushes over one branch is
|
|
@@ -5514,9 +5906,12 @@ export function createWorkManager({
|
|
|
5514
5906
|
});
|
|
5515
5907
|
return;
|
|
5516
5908
|
}
|
|
5517
|
-
// The branch changed, so the previous check
|
|
5518
|
-
// tree. Re-
|
|
5519
|
-
|
|
5909
|
+
// The branch changed, so the previous check — and the previous
|
|
5910
|
+
// pre-review — answered about a different tree. Re-read it before
|
|
5911
|
+
// anything merges: a failed merge sends the agent BACK to review, which
|
|
5912
|
+
// is a review-entry beat like any other, and a stale reading standing
|
|
5913
|
+
// over a rebased branch is exactly what `precheckSha` exists to void.
|
|
5914
|
+
await runReviewEntry(agentId, wt, job.agentName);
|
|
5520
5915
|
}
|
|
5521
5916
|
// `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
|
|
5522
5917
|
// a detached HEAD — so the guard below was unreachable and the throw
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "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": {
|