flowviant 0.97.0 → 0.99.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/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/** Agent review entry, project checks, and read-only pre-review turns. */
|
|
2
|
+
import { readFileSync, lstatSync, rmSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
6
|
+
import { git } from './git.mjs';
|
|
7
|
+
import { parsePrecheck } from './agentPlan.mjs';
|
|
8
|
+
import { readStash } from './agentCards.mjs';
|
|
9
|
+
import { c, note, warn } from './ui.mjs';
|
|
10
|
+
import { runTurn } from './claude.mjs';
|
|
11
|
+
import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
|
|
12
|
+
import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
|
|
13
|
+
import { scrub as envScrub } from './env.mjs';
|
|
14
|
+
import { pickRuntimeFor, removeProbeTranscript } from './runtimes.mjs';
|
|
15
|
+
import { taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
|
|
19
|
+
* THE PROJECT'S CHECK RUNS OVER THE WORKTREE (2026-09-24).
|
|
20
|
+
*
|
|
21
|
+
* A design or research turn may write under `.flowviant/artifacts/` and
|
|
22
|
+
* nowhere else — that posture's whole safety claim is that such a card
|
|
23
|
+
* changes no repository file. But the check is the repo's own command, run
|
|
24
|
+
* unattended in the same worktree the moment the queue empties, and a test
|
|
25
|
+
* runner's DEFAULT discovery reaches into that directory: vitest collected and
|
|
26
|
+
* ran `.flowviant/artifacts/pwn.test.js` with no include override. So a card
|
|
27
|
+
* steered by text it read could plant a test and have the machine execute it,
|
|
28
|
+
* before any person looked — and the directory is git-excluded, so the file
|
|
29
|
+
* never appears in the diff a reviewer reads.
|
|
30
|
+
*
|
|
31
|
+
* What an artifact IS is already a closed list (`ARTIFACT_TYPES`), and the
|
|
32
|
+
* scan only ever shows TOP-LEVEL regular files: anything else there is
|
|
33
|
+
* nothing the product will show, so removing it before the check costs
|
|
34
|
+
* nothing a person could see. Symlinks and subdirectories go whole; a
|
|
35
|
+
* `.flowviant` that is not a real directory is left alone (that is the
|
|
36
|
+
* repository's own content, not something a turn wrote). Returns the names
|
|
37
|
+
* removed, for the log line.
|
|
38
|
+
*/
|
|
39
|
+
export function clearNonArtifacts(wt) {
|
|
40
|
+
const removed = [];
|
|
41
|
+
try {
|
|
42
|
+
const parent = lstatSync(join(wt, '.flowviant'));
|
|
43
|
+
if (parent.isSymbolicLink() || !parent.isDirectory()) return removed;
|
|
44
|
+
const dir = join(wt, ARTIFACT_DIR);
|
|
45
|
+
const st = lstatSync(dir);
|
|
46
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
47
|
+
rmSync(dir, { force: true, recursive: false });
|
|
48
|
+
removed.push(ARTIFACT_DIR);
|
|
49
|
+
return removed;
|
|
50
|
+
}
|
|
51
|
+
for (const name of readdirSync(dir)) {
|
|
52
|
+
const p = join(dir, name);
|
|
53
|
+
let e;
|
|
54
|
+
try {
|
|
55
|
+
e = lstatSync(p);
|
|
56
|
+
} catch {
|
|
57
|
+
continue; // vanished between the list and the stat
|
|
58
|
+
}
|
|
59
|
+
const keep = e.isFile() && !name.startsWith('.') && artifactTypeFor(name) !== null;
|
|
60
|
+
if (keep) continue;
|
|
61
|
+
try {
|
|
62
|
+
rmSync(p, { recursive: true, force: true });
|
|
63
|
+
removed.push(name);
|
|
64
|
+
} catch {
|
|
65
|
+
/* best-effort — reported by omission */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
/* no artifacts directory — the ordinary case */
|
|
70
|
+
}
|
|
71
|
+
return removed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* THE ENVIRONMENT THE PROJECT'S CHECK RUNS UNDER: the daemon's own, minus the
|
|
76
|
+
* machine credential — the same rule `cliEnv` (claude.mjs) applies to a turn,
|
|
77
|
+
* repeated here rather than imported so the check never depends on the CLI
|
|
78
|
+
* module's spawn helpers. Nothing a check runs needs the credential the daemon
|
|
79
|
+
* authenticates with; everything else is what the agent's own turn saw when it
|
|
80
|
+
* ran the same tests.
|
|
81
|
+
*/
|
|
82
|
+
const CHECK_DROPPED_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
|
|
83
|
+
export function checkEnv(env = process.env) {
|
|
84
|
+
const out = { ...env };
|
|
85
|
+
for (const k of CHECK_DROPPED_ENV) delete out[k];
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* DID THE ACCOUNT HIT A LIMIT?
|
|
91
|
+
*
|
|
92
|
+
* Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
|
|
93
|
+
* and the matched line is relayed VERBATIM. It is a trigger, not a
|
|
94
|
+
* classifier: nothing here decides what an error "means" or writes a sentence
|
|
95
|
+
* of its own, because the product's own rule is that it relays and never
|
|
96
|
+
* infers. The honest limit is that a phrasing nobody listed reads as an
|
|
97
|
+
* ordinary failed turn — which lands the agent in Stuck with the CLI's words
|
|
98
|
+
* attached, and is a perfectly survivable second-best.
|
|
99
|
+
*/
|
|
100
|
+
const LIMIT_PHRASES = [
|
|
101
|
+
/usage limit reached/i,
|
|
102
|
+
/rate limit/i,
|
|
103
|
+
/you've reached your .* limit/i,
|
|
104
|
+
/quota exceeded/i,
|
|
105
|
+
/insufficient_quota/i,
|
|
106
|
+
];
|
|
107
|
+
export const limitLine = (text) => {
|
|
108
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
109
|
+
const t = line.trim();
|
|
110
|
+
if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function createWorkAgentReview({
|
|
116
|
+
repoRoot,
|
|
117
|
+
postBestEffort,
|
|
118
|
+
baseRef,
|
|
119
|
+
admit,
|
|
120
|
+
sessionMetaPath,
|
|
121
|
+
state,
|
|
122
|
+
}) {
|
|
123
|
+
const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
|
|
124
|
+
const AGENT_PRECHECK_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-precheck');
|
|
125
|
+
|
|
126
|
+
const { workChildren, groupKillChildren } = state;
|
|
127
|
+
|
|
128
|
+
// ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
|
|
129
|
+
//
|
|
130
|
+
// The check runs in the agent's own worktree the moment its queue empties, so
|
|
131
|
+
// a reviewer knows before they start reading whether they are reviewing
|
|
132
|
+
// working code. It LABELS the review row; it never blocks it.
|
|
133
|
+
//
|
|
134
|
+
// IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
|
|
135
|
+
// beside `deploy.json`, because a check travels with the code and changes
|
|
136
|
+
// with it — a setting in the app would go stale the first time somebody
|
|
137
|
+
// renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
|
|
138
|
+
// plenty of projects have no single command that means "is this alright".
|
|
139
|
+
//
|
|
140
|
+
// It runs through a shell, and that is no wider than what already happens in
|
|
141
|
+
// that directory: every turn in this worktree spawns a CLI with build
|
|
142
|
+
// permissions, so a repository that can run arbitrary code during a turn can
|
|
143
|
+
// run it here too. What this is NOT is the deleted dev-run supervisor —
|
|
144
|
+
// nothing here resolves a command, guesses a stack, or starts a server.
|
|
145
|
+
const CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
146
|
+
const CHECK_OUTPUT_CAP = 4000;
|
|
147
|
+
|
|
148
|
+
const readCheckCommand = () => {
|
|
149
|
+
try {
|
|
150
|
+
const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
|
|
151
|
+
const cfg = JSON.parse(raw);
|
|
152
|
+
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
153
|
+
return cmd ? cmd.slice(0, 500) : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null; // absent, unreadable or not JSON — all mean "no check"
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const postCheck = async (body) => {
|
|
160
|
+
// Same bounded retry as postAgentPlan — see postBestEffort. On the
|
|
161
|
+
// final failure the row simply keeps its previous answer, which is
|
|
162
|
+
// null the first time.
|
|
163
|
+
await postBestEffort(AGENT_CHECK_DONE_URL, body);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const runCheck = async (agentId, wt) => {
|
|
167
|
+
const cmd = readCheckCommand();
|
|
168
|
+
const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
|
|
169
|
+
if (!cmd) {
|
|
170
|
+
await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const cleared = clearNonArtifacts(wt);
|
|
174
|
+
if (cleared.length)
|
|
175
|
+
warn(
|
|
176
|
+
`agent ${agentId}: removed ${cleared.length} non-artifact file(s) from ${ARTIFACT_DIR} before the check — ${cleared.slice(0, 5).join(', ')}`
|
|
177
|
+
);
|
|
178
|
+
const out = await new Promise((resolve) => {
|
|
179
|
+
let text = '';
|
|
180
|
+
let done = false;
|
|
181
|
+
const finish = (status) => {
|
|
182
|
+
if (done) return;
|
|
183
|
+
done = true;
|
|
184
|
+
resolve({ status, text });
|
|
185
|
+
};
|
|
186
|
+
let child;
|
|
187
|
+
try {
|
|
188
|
+
// DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
|
|
189
|
+
// always a shell that spawns the real runner, and signalling the shell
|
|
190
|
+
// alone leaves the runner holding the worktree — and this place's
|
|
191
|
+
// WRITER lock — for as long as it likes.
|
|
192
|
+
//
|
|
193
|
+
// AND WITHOUT THE MACHINE CREDENTIAL. The check is the repo's command
|
|
194
|
+
// run with nobody watching, and it inherited `FLOWVIANT_MACHINE_TOKEN`
|
|
195
|
+
// — so anything the check executes (a test a turn wrote, a
|
|
196
|
+
// dependency's postinstall) could read the credential the whole
|
|
197
|
+
// machine authenticates with. It is `checkEnv()`, the environment the
|
|
198
|
+
// agent's own turn ran under (`cliEnv`'s rule), NOT `childEnv`'s
|
|
199
|
+
// allowlist: the agent runs these same tests in its turn, and a check
|
|
200
|
+
// stripped of `JAVA_HOME`, `DATABASE_URL` or the rest of the operator's
|
|
201
|
+
// shell would FAIL where the agent's own run passed — a "Check failed"
|
|
202
|
+
// the product manufactured and then relayed as the project's verdict.
|
|
203
|
+
// The planted-file path is closed by `clearNonArtifacts` above.
|
|
204
|
+
child = spawn(cmd, {
|
|
205
|
+
cwd: wt,
|
|
206
|
+
shell: true,
|
|
207
|
+
detached: true,
|
|
208
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
209
|
+
env: checkEnv(),
|
|
210
|
+
});
|
|
211
|
+
/**
|
|
212
|
+
* …AND IT IS TRACKED, so a stop or a takeover takes it with them.
|
|
213
|
+
*
|
|
214
|
+
* A check is a full test or build run in the agent's worktree, and it
|
|
215
|
+
* was the one long-lived child the daemon spawned without telling its
|
|
216
|
+
* own teardown about it. `shutdownWork` signalled the CLI children and
|
|
217
|
+
* left this one — so restarting the daemon, or a same-repo takeover,
|
|
218
|
+
* orphaned a running test suite inside a worktree the sweep may then
|
|
219
|
+
* try to remove. The ten-minute timer would eventually kill it, but by
|
|
220
|
+
* then it belongs to no daemon and nothing on any surface names it.
|
|
221
|
+
*
|
|
222
|
+
* Registered for a GROUP kill: with `shell: true` the child is
|
|
223
|
+
* `/bin/sh`, and signalling it leaves the runner it started behind —
|
|
224
|
+
* which is the process actually holding the worktree. See
|
|
225
|
+
* `groupKillChildren` for why this one is exempt from the
|
|
226
|
+
* never-signal-the-group rule.
|
|
227
|
+
*/
|
|
228
|
+
workChildren.set(child, agentId);
|
|
229
|
+
groupKillChildren.add(child);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
232
|
+
// resolved object, so assigning afterwards threw the spawn error away
|
|
233
|
+
// and the surface showed an empty failure.
|
|
234
|
+
text = String(e?.message || e);
|
|
235
|
+
finish('failed');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The TAIL, not the head: a failing check says why at the end. And
|
|
240
|
+
* SCRUBBED BEFORE IT IS CUT, which is the order that matters.
|
|
241
|
+
*
|
|
242
|
+
* It used to slice first: `text = (text + buf).slice(-CAP)`, with a
|
|
243
|
+
* single `envScrub` at the very end. `envScrub` replaces EXACT full
|
|
244
|
+
* values, so any credential straddling either boundary — the rolling
|
|
245
|
+
* window's, or a chunk's — was already cut in half by the time it was
|
|
246
|
+
* looked at, matched nothing, and the surviving tail was written to
|
|
247
|
+
* `agent.checkOutput` and shown to every member of the project. A failing
|
|
248
|
+
* integration test that dumps its environment is an ordinary way to reach
|
|
249
|
+
* that, and the partial is enough where the prefix of the key is a
|
|
250
|
+
* publicly known constant.
|
|
251
|
+
*
|
|
252
|
+
* Scrubbing on every chunk fixes both straddles at once: the accumulated
|
|
253
|
+
* text always holds the previous kept tail plus the whole new chunk, so a
|
|
254
|
+
* value split across chunks is whole here, and a value near the window
|
|
255
|
+
* edge is redacted before anything is discarded. Bounded work — the
|
|
256
|
+
* string is never longer than the cap plus one chunk.
|
|
257
|
+
*
|
|
258
|
+
* The one case it cannot cover is a secret LONGER than the cap itself,
|
|
259
|
+
* which can never sit in the window whole. The final scrub below stays as
|
|
260
|
+
* the second pass over what actually ships.
|
|
261
|
+
*/
|
|
262
|
+
const keep = (buf) => {
|
|
263
|
+
text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
264
|
+
};
|
|
265
|
+
child.stdout?.on('data', keep);
|
|
266
|
+
child.stderr?.on('data', keep);
|
|
267
|
+
const timer = setTimeout(() => {
|
|
268
|
+
try {
|
|
269
|
+
// The GROUP, not the child: killing the shell leaves whatever it
|
|
270
|
+
// started running, which is the thing actually taking ten minutes.
|
|
271
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
272
|
+
} catch {
|
|
273
|
+
try {
|
|
274
|
+
child.kill('SIGKILL');
|
|
275
|
+
} catch {
|
|
276
|
+
/* already gone */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
text += '\n[flowviant] the check ran past ten minutes and was stopped';
|
|
280
|
+
// RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
|
|
281
|
+
// hangs: if the group is already gone the event never arrives, and this
|
|
282
|
+
// promise holds the place's writer lock forever.
|
|
283
|
+
finish('failed');
|
|
284
|
+
}, CHECK_TIMEOUT_MS);
|
|
285
|
+
child.on('error', (e) => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
workChildren.delete(child);
|
|
288
|
+
text += String(e?.message || e);
|
|
289
|
+
finish('failed');
|
|
290
|
+
});
|
|
291
|
+
child.on('close', (code) => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
workChildren.delete(child);
|
|
294
|
+
finish(code === 0 ? 'passed' : 'failed');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
await postCheck({
|
|
298
|
+
agentId,
|
|
299
|
+
status: out.status,
|
|
300
|
+
output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
|
|
301
|
+
...(headSha ? { headSha } : {}),
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
|
|
306
|
+
//
|
|
307
|
+
// A FRESH Claude reads the branch before the human does. The owner asked for
|
|
308
|
+
// it in these words: "before having the user manually check, can we have the
|
|
309
|
+
// daemon … spawn an agent to review the work so basically we get an ai to look
|
|
310
|
+
// at the review before a human looks at it for a double check."
|
|
311
|
+
//
|
|
312
|
+
// IT IS NOT THE AGENT CHECKING ITSELF. `runTurn` is called with no `resume`,
|
|
313
|
+
// so there is no conversation to inherit: an agent that spent four turns
|
|
314
|
+
// arguing itself into a design defends that design, and asked whether its work
|
|
315
|
+
// meets the card it answers from the very context that produced the work. The
|
|
316
|
+
// reviewer stands IN the agent's worktree because it needs the code and the
|
|
317
|
+
// diff, under `readOnly` (`consultPermFor` — the fenced Read/Glob rules plus
|
|
318
|
+
// a few path-validated git reads) with NO MCP passed at all, so there is no
|
|
319
|
+
// control plane on this turn even if the repository it reads tries to steer
|
|
320
|
+
// it.
|
|
321
|
+
//
|
|
322
|
+
// IT LABELS AND NEVER BLOCKS — the check's own law, one function up. Approve,
|
|
323
|
+
// the per-card verdicts and the ship quiz do not know this exists. Every exit
|
|
324
|
+
// below POSTS NOTHING, and the server renders an absent precheck as nothing:
|
|
325
|
+
// a failed, timed-out, skipped or unparseable read leaves the human's review
|
|
326
|
+
// exactly as it was before this feature existed. Ignorance never withholds.
|
|
327
|
+
//
|
|
328
|
+
// NO VERSION FLOOR. This is a daemon→server report on a NEW endpoint, so an
|
|
329
|
+
// older daemon simply never posts, and an older SERVER 404s — which `postPre`
|
|
330
|
+
// treats as delivered-and-done for the agent-trace reason stated there.
|
|
331
|
+
//
|
|
332
|
+
// IT RIDES THE BEAT THE CHECK ALREADY OWNS (`runReviewEntry`), AFTER it: the
|
|
333
|
+
// check is a local command and this is a model call, so the cheap answer lands
|
|
334
|
+
// on the row first and a wedged reviewer cannot delay it.
|
|
335
|
+
/**
|
|
336
|
+
* FIVE MINUTES, and `runTurn` has no timer of its own.
|
|
337
|
+
*
|
|
338
|
+
* Half the planner's cap, because this turn is strictly smaller — it reads one
|
|
339
|
+
* branch's diff and answers, where a planner reads a repository to decide
|
|
340
|
+
* whether a batch of work collides. And it is HELD INSIDE THE PLACE WRITER
|
|
341
|
+
* LOCK by the beat it rides, so every minute here is a minute the agent's next
|
|
342
|
+
* turn (or its merge) is waiting: a generous cap on a label would be spending
|
|
343
|
+
* the work's time on a note about the work.
|
|
344
|
+
*/
|
|
345
|
+
const PRECHECK_TIMEOUT_MS = 5 * 60_000;
|
|
346
|
+
/** Commit subjects handed to the reviewer. A bound on the prompt, not on the
|
|
347
|
+
* branch — the reviewer reads the diff itself, and the log is context. */
|
|
348
|
+
const PRECHECK_LOG_LINES = 80;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* ONE PRE-REVIEW, POSTED.
|
|
352
|
+
*
|
|
353
|
+
* Resolves TRUE for a permanent refusal as well as a success, and that is
|
|
354
|
+
* deliberate — the `postAgentTrace` rule, for the same reason: a server with
|
|
355
|
+
* no such route 404s this body and will 404 every retry of it, so re-sending
|
|
356
|
+
* would be a wedge wearing a retry's clothes. A NETWORK error resolves false
|
|
357
|
+
* and is retried ONCE, because unlike a trace batch this body cost a whole
|
|
358
|
+
* model call and losing it to a blip means the operator paid for a label
|
|
359
|
+
* nobody ever sees.
|
|
360
|
+
*
|
|
361
|
+
* THE RETRY IS NOT A GUARANTEE THAT THE FIRST ATTEMPT FAILED, and since the
|
|
362
|
+
* body started carrying `usage` (0.90.0) that matters. The `catch` below
|
|
363
|
+
* takes the 30-second `AbortSignal` with everything else, so a server that
|
|
364
|
+
* was merely SLOW — and committed — receives this identical body twice. The
|
|
365
|
+
* store was always idempotent; the token counts riding it were not, and a
|
|
366
|
+
* timed-out-but-committed post added them twice. The SERVER closes that: its
|
|
367
|
+
* write refuses a body whose text already sits on the row, so a re-send
|
|
368
|
+
* stores the same reading and charges nothing for it. Nothing here needs to
|
|
369
|
+
* change, and nothing here may start assuming a thrown fetch means the
|
|
370
|
+
* server never saw this.
|
|
371
|
+
*/
|
|
372
|
+
const postPre = async (body) => {
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(AGENT_PRECHECK_URL, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers: {
|
|
377
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
378
|
+
'User-Agent': USER_AGENT,
|
|
379
|
+
'Content-Type': 'application/json',
|
|
380
|
+
},
|
|
381
|
+
signal: AbortSignal.timeout(30_000),
|
|
382
|
+
body: JSON.stringify(body),
|
|
383
|
+
});
|
|
384
|
+
return (
|
|
385
|
+
res.ok ||
|
|
386
|
+
(res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
387
|
+
);
|
|
388
|
+
} catch {
|
|
389
|
+
return false; // a blip — worth one more attempt at a model call's answer
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* THE BRANCH'S OWN COMMITS, subject + trailers, and the card ids they name.
|
|
395
|
+
*
|
|
396
|
+
* MEASURED, never asserted — the same reason `commitsBetween` exists. The
|
|
397
|
+
* trailer ids are what let the prompt say "N earlier cards' specs are not on
|
|
398
|
+
* this box" honestly: the difference between the cards this branch claims and
|
|
399
|
+
* the specs this disk holds is a fact, and a box that adopted the agent
|
|
400
|
+
* mid-run is exactly the case where it is non-zero.
|
|
401
|
+
*
|
|
402
|
+
* IT CANNOT THROW. `baseRef()` is free text an owner typed and may name no
|
|
403
|
+
* ref at all; an unreadable range costs the reviewer its context, never the
|
|
404
|
+
* review-entry beat it is standing in.
|
|
405
|
+
*/
|
|
406
|
+
const branchLog = (wt) => {
|
|
407
|
+
let out;
|
|
408
|
+
try {
|
|
409
|
+
out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
|
|
410
|
+
} catch {
|
|
411
|
+
return { text: '', taskIds: [] };
|
|
412
|
+
}
|
|
413
|
+
if (typeof out !== 'string') return { text: '', taskIds: [] };
|
|
414
|
+
// LINEAR, never backtracking (audit 2026-09-24) — this was
|
|
415
|
+
// `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
|
|
416
|
+
// quadratic against a long trailing run of whitespace on one line
|
|
417
|
+
// (measured ~1s at 40KB), and a branch's own commit log is exactly the
|
|
418
|
+
// kind of text an agent's commits can grow past that. `taskIdsFromMessage`
|
|
419
|
+
// is the same line-split, length-capped reader every other trailer scan
|
|
420
|
+
// in this codebase uses.
|
|
421
|
+
const taskIds = taskIdsFromMessage(out);
|
|
422
|
+
const text = out
|
|
423
|
+
.split('\n')
|
|
424
|
+
.filter((l) => l.trim())
|
|
425
|
+
.slice(0, PRECHECK_LOG_LINES)
|
|
426
|
+
.join('\n');
|
|
427
|
+
return { text, taskIds: [...taskIds] };
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const runPrecheck = async (agentId, wt, agentName) => {
|
|
431
|
+
/**
|
|
432
|
+
* WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
|
|
433
|
+
* planner uses, and for the same reason: this prompt was written against
|
|
434
|
+
* Claude, and a machine with no read-only-capable runtime simply does not
|
|
435
|
+
* produce a precheck. Nothing is posted and nothing is said on the row.
|
|
436
|
+
*/
|
|
437
|
+
const rt = pickRuntimeFor('consult');
|
|
438
|
+
if (!rt) return;
|
|
439
|
+
/**
|
|
440
|
+
* THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
|
|
441
|
+
* asks it. `churn` and never `interactive`: nobody is watching this, and a
|
|
442
|
+
* label is the first thing that should not be started on a box that is
|
|
443
|
+
* struggling. Deferring here does NOT queue anything — there is no job and
|
|
444
|
+
* no re-offer — so a precheck skipped under pressure is simply a precheck
|
|
445
|
+
* that did not happen, which is what absence already means.
|
|
446
|
+
*/
|
|
447
|
+
const hold = admit('churn');
|
|
448
|
+
if (hold) {
|
|
449
|
+
note(`${c.cyan('pre-review')} ${c.dim(`— skipped: ${hold.reason}`)}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const releaseSlot = admit.reserve();
|
|
453
|
+
|
|
454
|
+
// THE HEAD THE READING BELONGS TO, taken BEFORE the turn — the
|
|
455
|
+
// `checkFingerprint` shape, so a commit landing after this voids it rather
|
|
456
|
+
// than letting an old reading label a branch it never saw. Optional: an
|
|
457
|
+
// unreadable head costs the staleness comparison, not the precheck.
|
|
458
|
+
let headSha = null;
|
|
459
|
+
try {
|
|
460
|
+
headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
|
|
461
|
+
} catch {
|
|
462
|
+
headSha = null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
|
|
466
|
+
const log = branchLog(wt);
|
|
467
|
+
/** Cards the BRANCH names that this box has no spec for. Measured, not
|
|
468
|
+
* guessed — see agentCards.mjs on why a stash is per-box. */
|
|
469
|
+
const held = new Set(stash.map((s) => s.taskId));
|
|
470
|
+
const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
|
|
471
|
+
|
|
472
|
+
let out = '';
|
|
473
|
+
let child = null;
|
|
474
|
+
let timer = null;
|
|
475
|
+
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
476
|
+
let wedged = false;
|
|
477
|
+
/**
|
|
478
|
+
* WHAT THE READING SPENT — the agent-turn lane's own relay, on the one
|
|
479
|
+
* other lane that charges an AGENT (2026-09-19).
|
|
480
|
+
*
|
|
481
|
+
* It rides the agent and never a card link: a pre-review belongs to no card
|
|
482
|
+
* (the same reason `workChildren` gets a null task id below), so the four
|
|
483
|
+
* counters it adds to are the container's alone. That is also why an
|
|
484
|
+
* agent's links can never sum to its total, and the schema says so.
|
|
485
|
+
*
|
|
486
|
+
* A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
|
|
487
|
+
* nothing either — which under-reports a turn that really did spend. That
|
|
488
|
+
* is the honest direction: there is no row to put it on, and inventing a
|
|
489
|
+
* settle to carry a number would be a post whose only content is a bill.
|
|
490
|
+
*/
|
|
491
|
+
let usage = null;
|
|
492
|
+
/**
|
|
493
|
+
* THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
|
|
494
|
+
* to DELETE the transcript it leaves behind (review, 2026-09-17).
|
|
495
|
+
*
|
|
496
|
+
* This is the first thing in the daemon to run a second `claude -p` inside
|
|
497
|
+
* an AGENT's worktree, and the agent's own resume is `--continue`, which is
|
|
498
|
+
* CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
|
|
499
|
+
* ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
|
|
500
|
+
* Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
|
|
501
|
+
* makes the read-only stranger the newest conversation in that directory,
|
|
502
|
+
* so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
|
|
503
|
+
* a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
|
|
504
|
+
* READ-ONLY" instead of its own four-turn context, under build permissions.
|
|
505
|
+
* That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
|
|
506
|
+
* arriving a third time by a third route.
|
|
507
|
+
*
|
|
508
|
+
* `removeProbeTranscript` is exported for precisely this and already serves
|
|
509
|
+
* the skills probe and the dev-command resolver.
|
|
510
|
+
*/
|
|
511
|
+
let preSession = null;
|
|
512
|
+
try {
|
|
513
|
+
/**
|
|
514
|
+
* THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
|
|
515
|
+
* the kill — the shape the project check and the planner both keep. A
|
|
516
|
+
* SIGKILLed process whose stdio a grandchild still holds can be slow to
|
|
517
|
+
* emit `close`, or never emit it, and this promise is inside the place's
|
|
518
|
+
* writer lock.
|
|
519
|
+
*/
|
|
520
|
+
let stopWaiting = () => {};
|
|
521
|
+
const capped = new Promise((r) => {
|
|
522
|
+
stopWaiting = r;
|
|
523
|
+
});
|
|
524
|
+
const turn = runTurn({
|
|
525
|
+
prompt: AGENT_PRECHECK_KICKOFF({
|
|
526
|
+
agentName,
|
|
527
|
+
cards: stash.map((s) => s.prompt).join('\n---\n'),
|
|
528
|
+
missingSpecs,
|
|
529
|
+
commits: log.text,
|
|
530
|
+
diffCommand: `git diff ${baseRef()}...HEAD`,
|
|
531
|
+
}),
|
|
532
|
+
system: SYSTEM_PRECHECK,
|
|
533
|
+
// READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
|
|
534
|
+
// passed empty, so there is no control plane on this turn at all.
|
|
535
|
+
readOnly: true,
|
|
536
|
+
cwd: wt,
|
|
537
|
+
runtime: rt,
|
|
538
|
+
// NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
|
|
539
|
+
// agent grading its own homework out of its own context; this is a
|
|
540
|
+
// stranger reading a diff.
|
|
541
|
+
streamJson: true,
|
|
542
|
+
answerFromResult: true,
|
|
543
|
+
label: c.cyan('[pre-review]'),
|
|
544
|
+
// The id the CLI reports at `system.init` — harvested off the stream
|
|
545
|
+
// this turn already parses, no probe and no extra spawn. Held only so
|
|
546
|
+
// the `finally` below can delete this turn's transcript; see
|
|
547
|
+
// `preSession`.
|
|
548
|
+
onInit: (i) => {
|
|
549
|
+
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
550
|
+
preSession = i.sessionId.trim();
|
|
551
|
+
},
|
|
552
|
+
// The CLI's own count for this reading. Set, never accumulated: one
|
|
553
|
+
// turn is one `result` event, and the adding-up is the server's.
|
|
554
|
+
// Tagged with the CLI, so a Claude pre-review of a Codex agent is
|
|
555
|
+
// charged as Claude's and not folded into the agent's own figure.
|
|
556
|
+
onUsage: (u) => {
|
|
557
|
+
usage = { ...u, runtime: rt };
|
|
558
|
+
},
|
|
559
|
+
onSpawn: (ch) => {
|
|
560
|
+
child = ch;
|
|
561
|
+
// No task id: a pre-review belongs to no card, and the machine
|
|
562
|
+
// snapshot's per-task rows must not invent one. It still COUNTS
|
|
563
|
+
// against the machine's ceiling — see liveTurnCount.
|
|
564
|
+
workChildren.set(ch, null);
|
|
565
|
+
releaseSlot();
|
|
566
|
+
// ARMED AT THE SPAWN, not at entry: time spent getting here is not a
|
|
567
|
+
// wedged CLI. The CHILD and never its group — a read-only turn starts
|
|
568
|
+
// no server, so there is nothing behind it worth signalling and
|
|
569
|
+
// everything to lose by signalling somebody else's.
|
|
570
|
+
timer = setTimeout(() => {
|
|
571
|
+
wedged = true;
|
|
572
|
+
try {
|
|
573
|
+
ch.kill('SIGKILL');
|
|
574
|
+
} catch {
|
|
575
|
+
/* already gone */
|
|
576
|
+
}
|
|
577
|
+
stopWaiting('');
|
|
578
|
+
}, PRECHECK_TIMEOUT_MS);
|
|
579
|
+
timer.unref?.();
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
out = await Promise.race([turn, capped]);
|
|
583
|
+
} catch {
|
|
584
|
+
return; // a label may never fail the beat it rides
|
|
585
|
+
} finally {
|
|
586
|
+
if (timer) clearTimeout(timer);
|
|
587
|
+
if (child) workChildren.delete(child);
|
|
588
|
+
releaseSlot();
|
|
589
|
+
/**
|
|
590
|
+
* DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
|
|
591
|
+
* killed, or thrown — because every one of them leaves the file behind
|
|
592
|
+
* and the agent's `--continue` reads the newest one in the directory.
|
|
593
|
+
*
|
|
594
|
+
* AFTER the kill and ON A DELAY, the skills probe's own shape: the
|
|
595
|
+
* transcript is the CHILD's file, so removing it while the child is still
|
|
596
|
+
* dying races a recreate. Unref'd — it must not hold the process open.
|
|
597
|
+
*/
|
|
598
|
+
if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
|
|
602
|
+
// waiting on an answer, so the honest record is that no pre-review exists —
|
|
603
|
+
// the same silence a machine that never ran one leaves.
|
|
604
|
+
if (wedged) {
|
|
605
|
+
note(`${c.cyan('pre-review')} ${c.dim('— ran past five minutes and was stopped')}`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* SCRUBBED ON THE WAY OUT, every string — AND SCRUBBED BEFORE IT IS CUT,
|
|
611
|
+
* which is the order that matters and the reason `envScrub` rides INTO the
|
|
612
|
+
* parser rather than being applied to what comes back out.
|
|
613
|
+
*
|
|
614
|
+
* This turn read the repository with `cat` and `git show` in a worktree
|
|
615
|
+
* holding the project's materialized dev secrets, and its answer is about to
|
|
616
|
+
* be stored and rendered to every member of the project. `scrub` replaces
|
|
617
|
+
* EXACT full values, so a note capped first and scrubbed second hands the
|
|
618
|
+
* scrub a credential already cut in half: it matches nothing and the
|
|
619
|
+
* surviving prefix ships. The check's output lane learned exactly that the
|
|
620
|
+
* expensive way, and this lane relearned it in review (2026-09-17) — see
|
|
621
|
+
* `parsePrecheck`, which now caps only what it has already redacted.
|
|
622
|
+
*/
|
|
623
|
+
const result = parsePrecheck(out, envScrub);
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* A QUOTA LIMIT SKIPS THE PRE-REVIEW AND PARKS NOTHING — AND A LIMIT IS
|
|
627
|
+
* ONLY A LIMIT WHEN THE READING PRODUCED NOTHING.
|
|
628
|
+
*
|
|
629
|
+
* `limitLine` is a literal phrase match over the CLI's whole output, and
|
|
630
|
+
* under `answerFromResult` that output IS the reviewer's answer — so a
|
|
631
|
+
* pre-review OF rate-limiting code, or any triage that quotes the phrase,
|
|
632
|
+
* read as a quota failure and threw a perfectly good reading away. The
|
|
633
|
+
* agent-turn lane fixed this exact false positive once (`const limit = res
|
|
634
|
+
* ? null : limitLine(out)`); gating on "nothing parsed" is what makes the
|
|
635
|
+
* match mean what it says.
|
|
636
|
+
*
|
|
637
|
+
* And when it IS a limit, nothing parks. `postAgentParked` stops EVERY
|
|
638
|
+
* agent on the project, because the CLI login is shared — the right answer
|
|
639
|
+
* when the thing that hit the limit was somebody's actual work, the wrong
|
|
640
|
+
* one here: parking a whole fleet because a LABEL could not be written
|
|
641
|
+
* would let an optional readout take the product's primary lane down. The
|
|
642
|
+
* branch is still reviewable; it just has no note on it.
|
|
643
|
+
*/
|
|
644
|
+
if (!result) {
|
|
645
|
+
if (limitLine(out)) {
|
|
646
|
+
note(`${c.cyan('pre-review')} ${c.dim('— skipped: the CLI reported a limit')}`);
|
|
647
|
+
}
|
|
648
|
+
// UNPARSEABLE POSTS NOTHING. A half-read triage is a plausible-looking
|
|
649
|
+
// paragraph nobody wrote, rendered on the surface where somebody decides
|
|
650
|
+
// whether a branch reaches main — see parsePrecheck.
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const body = {
|
|
655
|
+
agentId,
|
|
656
|
+
...(headSha ? { headSha } : {}),
|
|
657
|
+
...(usage ? { usage } : {}),
|
|
658
|
+
cards: result.cards.map((cd) => ({
|
|
659
|
+
taskId: cd.taskId,
|
|
660
|
+
verdict: cd.verdict,
|
|
661
|
+
...(cd.note ? { note: cd.note } : {}),
|
|
662
|
+
})),
|
|
663
|
+
...(result.overall ? { overall: result.overall } : {}),
|
|
664
|
+
};
|
|
665
|
+
// ONE RETRY, and only for a network error — see `postPre`. A permanent
|
|
666
|
+
// refusal is an older server, and asking it again changes nothing.
|
|
667
|
+
if (!(await postPre(body))) await postPre(body);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* REVIEW ENTRY — everything this machine does the moment an agent's queue
|
|
672
|
+
* empties, in one place.
|
|
673
|
+
*
|
|
674
|
+
* It exists so the two readings cannot drift apart at the three call sites
|
|
675
|
+
* that own this beat (the settle reply, the held body's re-POST, and the
|
|
676
|
+
* stale-merge re-read after base is folded in). Each of those used to call
|
|
677
|
+
* `runCheck` directly; a second thing to run at the same moment is a second
|
|
678
|
+
* thing three call sites can forget.
|
|
679
|
+
*
|
|
680
|
+
* THE CHECK FIRST, ALWAYS. It is a local command whose answer the board wants
|
|
681
|
+
* on the row immediately; the pre-review is a model call that may take
|
|
682
|
+
* minutes. Ordering them the other way would put a label behind a label.
|
|
683
|
+
*
|
|
684
|
+
* NEITHER MAY THROW PAST THIS POINT. Both are optional readouts and both run
|
|
685
|
+
* INSIDE the place's writer lock on a path whose callers settle real work —
|
|
686
|
+
* `runAgentMerge` in particular reports a claimed merge after this returns.
|
|
687
|
+
*/
|
|
688
|
+
const runReviewEntry = async (agentId, wt, agentName) => {
|
|
689
|
+
try {
|
|
690
|
+
await runCheck(agentId, wt);
|
|
691
|
+
} catch {
|
|
692
|
+
/* the row keeps its previous check answer, which is null the first time */
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
await runPrecheck(agentId, wt, agentName);
|
|
696
|
+
} catch {
|
|
697
|
+
/* no pre-review is posted, and absence renders nothing */
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
return { runReviewEntry };
|
|
702
|
+
}
|