flowviant 0.34.2 → 0.35.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/claude.mjs +14 -7
- package/bin/lib/config.mjs +55 -11
- package/bin/lib/fleet.mjs +125 -38
- package/bin/lib/git.mjs +147 -0
- package/bin/lib/live.mjs +134 -22
- package/bin/lib/resources.mjs +127 -0
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -603,13 +603,20 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
603
603
|
if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
|
|
604
604
|
// readOnly wins over wikiPerm: a consult must never inherit write tools.
|
|
605
605
|
args.push(...(readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM));
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
606
|
+
// Whatever this machine is signed in with, we use. We do NOT pick.
|
|
607
|
+
//
|
|
608
|
+
// This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
|
|
609
|
+
// the subscription path, which was right when the daemon ran on a
|
|
610
|
+
// developer's laptop: a key left in their shell would silently bill every
|
|
611
|
+
// turn as raw API usage instead of the plan they were already paying for.
|
|
612
|
+
// On a machine the project leaves running, an inherited org key is the
|
|
613
|
+
// POINT — deleting it is the daemon overriding the credential its operator
|
|
614
|
+
// deliberately configured.
|
|
615
|
+
//
|
|
616
|
+
// Which credential is correct, and whether an account may be shared, is
|
|
617
|
+
// between the operator and Anthropic. Flowviant does not detect it and does
|
|
618
|
+
// not enforce it; it runs Claude the ordinary way and relays what happens.
|
|
619
|
+
const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
613
620
|
onSpawn?.(child);
|
|
614
621
|
let out = '';
|
|
615
622
|
const pfx = label ? `${label} ` : '';
|
package/bin/lib/config.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { homedir, cpus } from 'node:os';
|
|
6
|
+
import { homedir, cpus, totalmem } from 'node:os';
|
|
7
7
|
|
|
8
8
|
// Read the daemon's version from its OWN package.json (always shipped in the npm
|
|
9
9
|
// tarball) — never hardcode it. The hardcoded constant drifted: it sat at
|
|
@@ -59,30 +59,74 @@ export const STREAM_URL =
|
|
|
59
59
|
FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
|
|
60
60
|
export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* What this machine can actually be given, read from the machine.
|
|
64
|
+
*
|
|
65
|
+
* `os.totalmem()` and `cpus().length` report the HOST inside a container: a
|
|
66
|
+
* 4GB container on a 256GB box reads 256GB and cheerfully oversubscribes until
|
|
67
|
+
* the OOM killer picks a victim — which, because it picks by resident size, is
|
|
68
|
+
* frequently not the task that caused it. cgroup v2 publishes the real limits,
|
|
69
|
+
* so read those first and treat the os module as the fallback it is.
|
|
70
|
+
*/
|
|
71
|
+
function machineLimits() {
|
|
72
|
+
const readCgroup = (f) => {
|
|
73
|
+
try {
|
|
74
|
+
return readFileSync(`/sys/fs/cgroup/${f}`, 'utf8').trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
let memBytes = totalmem();
|
|
80
|
+
const memMax = readCgroup('memory.max');
|
|
81
|
+
if (memMax && memMax !== 'max') {
|
|
82
|
+
const n = Number(memMax);
|
|
83
|
+
if (Number.isFinite(n) && n > 0) memBytes = Math.min(memBytes, n);
|
|
84
|
+
}
|
|
85
|
+
let cores = cpus().length || 2;
|
|
86
|
+
// "<quota> <period>" in microseconds, or "max <period>" for unlimited.
|
|
87
|
+
const cpuMax = readCgroup('cpu.max');
|
|
88
|
+
if (cpuMax && !cpuMax.startsWith('max')) {
|
|
89
|
+
const [q, p] = cpuMax.split(/\s+/).map(Number);
|
|
90
|
+
if (Number.isFinite(q) && Number.isFinite(p) && p > 0) {
|
|
91
|
+
cores = Math.max(1, Math.min(cores, Math.floor(q / p)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { memBytes, cores };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const MACHINE = machineLimits();
|
|
98
|
+
|
|
62
99
|
/**
|
|
63
100
|
* How many tasks THIS MACHINE will build at once.
|
|
64
101
|
*
|
|
65
102
|
* The limit belongs here, not on the server: a task in flight is a Claude Code
|
|
66
103
|
* session plus its own git worktree plus whatever the project's dev server and
|
|
67
104
|
* tests want, and this process is the only party that can see the cores, the
|
|
68
|
-
* RAM and the fan.
|
|
69
|
-
* a user had pre-sized with a dial — which asked them to answer a question
|
|
70
|
-
* about their laptop in a web app, before they knew what they were going to
|
|
71
|
-
* dispatch.
|
|
105
|
+
* RAM and the fan.
|
|
72
106
|
*
|
|
73
107
|
* Sent to the server on every roster poll so it can grow lanes to meet waiting
|
|
74
108
|
* work UNDER this ceiling, and enforced locally besides — the roster can carry
|
|
75
|
-
* more lanes than this
|
|
76
|
-
*
|
|
109
|
+
* more lanes than this, and a ceiling that only exists as a request is not one.
|
|
110
|
+
*
|
|
111
|
+
* MEMORY is the bound, not cores. Cores oversubscribe gracefully (everything
|
|
112
|
+
* gets slower); memory does not (something dies, and not necessarily the
|
|
113
|
+
* offender). A task is a Claude session plus a dev server plus whatever the
|
|
114
|
+
* test runner spawns — call it 2GB, keep 2GB back for the operating system,
|
|
115
|
+
* and let cores cap it only when they are the scarcer thing.
|
|
77
116
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
117
|
+
* This used to be `min(4, cores/2)` — half the cores because "the user is
|
|
118
|
+
* working on this machine too", and a hard 4 because a personal Claude plan
|
|
119
|
+
* ran out before the CPU did. Both were laptop assumptions. The machine is now
|
|
120
|
+
* a box the project leaves running, so it reserves one core rather than half of
|
|
121
|
+
* them, and the ceiling is what the hardware can hold rather than a guess about
|
|
122
|
+
* somebody's plan.
|
|
81
123
|
*/
|
|
82
124
|
export const MAX_CONCURRENT = (() => {
|
|
83
125
|
const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
|
|
84
126
|
if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
|
|
85
|
-
|
|
127
|
+
const byMem = Math.floor((MACHINE.memBytes / 2 ** 30 - 2) / 2);
|
|
128
|
+
const byCpu = MACHINE.cores - 1;
|
|
129
|
+
return Math.max(1, Math.min(32, byMem, byCpu));
|
|
86
130
|
})();
|
|
87
131
|
export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
|
|
88
132
|
// Live mode: after this long idle-parked on a blocker, tear the session down to
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
8
|
+
import { mkdirSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
@@ -30,6 +30,7 @@ import { handleVersionSignal } from './update.mjs';
|
|
|
30
30
|
import {
|
|
31
31
|
git,
|
|
32
32
|
resetWorktree,
|
|
33
|
+
ensureWorktree,
|
|
33
34
|
repoRootOrDie,
|
|
34
35
|
detectBaseRef,
|
|
35
36
|
originSlug,
|
|
@@ -73,6 +74,7 @@ import {
|
|
|
73
74
|
scrub as envScrub,
|
|
74
75
|
} from './env.mjs';
|
|
75
76
|
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
77
|
+
import { machineSnapshot } from './resources.mjs';
|
|
76
78
|
|
|
77
79
|
async function fetchRoster(haveIds) {
|
|
78
80
|
const url = new URL(FLEET_URL);
|
|
@@ -227,11 +229,31 @@ export async function runFleetDaemon() {
|
|
|
227
229
|
reapOrphanPreviews((m) => info(m));
|
|
228
230
|
|
|
229
231
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
230
|
-
// so Ctrl+C mid-task never loses local work. Keyed per repo path
|
|
231
|
-
// agent's worktree carries a task marker so a resumed claim keeps its files.
|
|
232
|
+
// so Ctrl+C mid-task never loses local work. Keyed per repo path.
|
|
232
233
|
const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
|
|
233
234
|
const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
|
|
234
235
|
mkdirSync(baseDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
// ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
|
|
238
|
+
// `agent-<agentId>` — a long-lived tree per lane, reset to base between
|
|
239
|
+
// tasks — and that was the last thing a lane owned. Now a lane is a
|
|
240
|
+
// credential and nothing more, which is what makes it disposable: the server
|
|
241
|
+
// can hand any lane any task, and two tasks can never be in each other's
|
|
242
|
+
// files even when one is mid-edit.
|
|
243
|
+
const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
|
|
244
|
+
const worktreeFor = (intentId) => {
|
|
245
|
+
const r = ensureWorktree(repoRoot, taskWorktreePath(intentId), baseRef);
|
|
246
|
+
// Only on creation: a resumed tree already has its env, and rewriting it
|
|
247
|
+
// mid-task would clobber anything the agent changed.
|
|
248
|
+
if (r.fresh) {
|
|
249
|
+
try {
|
|
250
|
+
materializeInto(r.path);
|
|
251
|
+
} catch {
|
|
252
|
+
/* best-effort — the task still builds, secrets-backed paths may 500 */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return r;
|
|
256
|
+
};
|
|
235
257
|
try {
|
|
236
258
|
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
237
259
|
if (kb > 1024)
|
|
@@ -241,6 +263,38 @@ export async function runFleetDaemon() {
|
|
|
241
263
|
} catch {
|
|
242
264
|
/* du unavailable (Windows) — skip the disk line */
|
|
243
265
|
}
|
|
266
|
+
|
|
267
|
+
// Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
|
|
268
|
+
// N directories, reused forever. Per-task trees are not: every task ever
|
|
269
|
+
// built leaves one behind, so without this the disk grows without bound and
|
|
270
|
+
// `flowviant clean` becomes a chore rather than a convenience.
|
|
271
|
+
//
|
|
272
|
+
// Age, not state, is the test. The daemon has no list of which intents are
|
|
273
|
+
// still open, and asking the server for one would put a delete behind a
|
|
274
|
+
// network call that can fail — so anything untouched for a fortnight goes,
|
|
275
|
+
// which is far beyond how long a task stays reviewable and far beyond any
|
|
276
|
+
// pause a human takes mid-build. Runs at startup only: mid-run this would
|
|
277
|
+
// race a worker that is quietly parked on a blocker.
|
|
278
|
+
try {
|
|
279
|
+
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
280
|
+
let reaped = 0;
|
|
281
|
+
for (const name of readdirSync(baseDir)) {
|
|
282
|
+
if (!name.startsWith('task-')) continue;
|
|
283
|
+
const p = join(baseDir, name);
|
|
284
|
+
try {
|
|
285
|
+
if (statSync(p).mtimeMs > cutoff) continue;
|
|
286
|
+
// Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
|
|
287
|
+
// a stale entry that blocks re-adding the same path later.
|
|
288
|
+
git(['worktree', 'remove', '--force', p], repoRoot);
|
|
289
|
+
reaped++;
|
|
290
|
+
} catch {
|
|
291
|
+
/* held, gone, or not ours — leave it for `flowviant clean` */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
|
|
295
|
+
} catch {
|
|
296
|
+
/* the worktree home may not exist yet on a first run */
|
|
297
|
+
}
|
|
244
298
|
const tokenByAgent = new Map(); // agentId -> latest worker token
|
|
245
299
|
const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
|
|
246
300
|
const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
|
|
@@ -486,6 +540,8 @@ export async function runFleetDaemon() {
|
|
|
486
540
|
};
|
|
487
541
|
|
|
488
542
|
const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
|
|
543
|
+
// Machine telemetry — what the box is doing with itself, for the admin view.
|
|
544
|
+
const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
|
|
489
545
|
const checkingPlans = new Set();
|
|
490
546
|
const processPlanCheckJobs = (jobs) => {
|
|
491
547
|
for (const job of jobs ?? []) {
|
|
@@ -561,16 +617,17 @@ export async function runFleetDaemon() {
|
|
|
561
617
|
let joinChain = Promise.resolve();
|
|
562
618
|
|
|
563
619
|
/** The worktree currently building this intent, or null if this machine isn't.
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
620
|
+
* Now a direct lookup rather than a scan: a task's checkout is named after
|
|
621
|
+
* the task, so there is exactly one place it could be. The marker is still
|
|
622
|
+
* consulted, but for LIFECYCLE rather than identity — a directory that
|
|
623
|
+
* outlived its run (finished, cleared its marker, kept for the review
|
|
624
|
+
* preview) exists but is not building anything, and must not take an edit. */
|
|
567
625
|
const worktreeBuilding = (intentId) => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
626
|
+
const wt = taskWorktreePath(intentId);
|
|
627
|
+
try {
|
|
628
|
+
if (existsSync(wt) && readTaskMarker(wt) === intentId) return { wt };
|
|
629
|
+
} catch {
|
|
630
|
+
/* a worktree that vanished isn't building anything */
|
|
574
631
|
}
|
|
575
632
|
return null;
|
|
576
633
|
};
|
|
@@ -1320,36 +1377,33 @@ export async function runFleetDaemon() {
|
|
|
1320
1377
|
}
|
|
1321
1378
|
continue;
|
|
1322
1379
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1380
|
+
// LIVE lanes get NO checkout of their own — they ask for one per task,
|
|
1381
|
+
// once they know which task. Poll mode is the legacy escape hatch and
|
|
1382
|
+
// keeps its per-lane tree; it predates per-task sandboxes and isn't
|
|
1383
|
+
// worth restructuring for a path nobody runs by default.
|
|
1384
|
+
let wt = null;
|
|
1385
|
+
if (!LIVE) {
|
|
1386
|
+
try {
|
|
1387
|
+
ensureWorktree(repoRoot, (wt = join(baseDir, `agent-${a.agentId}`)), baseRef);
|
|
1388
|
+
} catch (e) {
|
|
1389
|
+
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
try {
|
|
1393
|
+
materializeInto(wt); // synced env into the fresh worktree
|
|
1394
|
+
} catch {
|
|
1395
|
+
/* best-effort */
|
|
1334
1396
|
}
|
|
1335
|
-
} catch (e) {
|
|
1336
|
-
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
1337
|
-
continue;
|
|
1338
|
-
}
|
|
1339
|
-
try {
|
|
1340
|
-
materializeInto(wt); // synced env into the fresh worktree
|
|
1341
|
-
} catch {
|
|
1342
|
-
/* best-effort */
|
|
1343
1397
|
}
|
|
1344
1398
|
const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
|
|
1345
1399
|
const label = colorFn(`[${a.name}]`);
|
|
1346
1400
|
const state = { alive: true, child: null };
|
|
1347
|
-
ok(`${label} ${c.dim(
|
|
1401
|
+
ok(`${label} ${c.dim(LIVE ? 'online — live session' : 'online — worktree ready')}`);
|
|
1348
1402
|
const workerFn = LIVE ? runLiveWorker : runFleetWorker;
|
|
1349
1403
|
const promise = workerFn({
|
|
1350
1404
|
agentId: a.agentId,
|
|
1351
1405
|
label,
|
|
1352
|
-
cwd: wt,
|
|
1406
|
+
...(LIVE ? { worktreeFor } : { cwd: wt }),
|
|
1353
1407
|
baseRef,
|
|
1354
1408
|
repoRoot, // for copying the repo's local env into the preview worktree
|
|
1355
1409
|
|
|
@@ -1360,6 +1414,12 @@ export async function runFleetDaemon() {
|
|
|
1360
1414
|
onChild: (ch) => {
|
|
1361
1415
|
state.child = ch;
|
|
1362
1416
|
},
|
|
1417
|
+
// Which task this lane is holding, so per-process memory can be
|
|
1418
|
+
// attributed to a task rather than to an anonymous pid. "The box is
|
|
1419
|
+
// full" is not actionable; "this task is holding 9GB" is.
|
|
1420
|
+
onIntent: (id) => {
|
|
1421
|
+
state.intentId = id;
|
|
1422
|
+
},
|
|
1363
1423
|
// Hold the preview's stop fn so teardown/removal can kill the detached
|
|
1364
1424
|
// dev-server + tunnel (they survive our exit otherwise).
|
|
1365
1425
|
onPreview: (stop) => {
|
|
@@ -1404,6 +1464,27 @@ export async function runFleetDaemon() {
|
|
|
1404
1464
|
}
|
|
1405
1465
|
});
|
|
1406
1466
|
|
|
1467
|
+
// Tell the app what this machine is doing with itself. Every reconcile,
|
|
1468
|
+
// best-effort, and never awaited — telemetry that can delay a dispatch is
|
|
1469
|
+
// worse than no telemetry.
|
|
1470
|
+
//
|
|
1471
|
+
// The one thing Flowviant could never answer about a task was "why is it
|
|
1472
|
+
// slow", because the box was somebody's laptop and only they could look at
|
|
1473
|
+
// it. Centralising is supposed to make one machine easier to manage than N
|
|
1474
|
+
// laptops; that is only true if the machine is visible. Per-task RSS is the
|
|
1475
|
+
// load-bearing part — "the box is full" is not actionable, "this task is
|
|
1476
|
+
// holding 9GB" is.
|
|
1477
|
+
void reportMergeOutcome(
|
|
1478
|
+
MACHINE_URL,
|
|
1479
|
+
machineSnapshot({
|
|
1480
|
+
worktreeDir: baseDir,
|
|
1481
|
+
tasks: [...workers].map(([, w]) => ({
|
|
1482
|
+
intentId: w.state.intentId ?? null,
|
|
1483
|
+
pid: w.state.child?.pid,
|
|
1484
|
+
})),
|
|
1485
|
+
})
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1407
1488
|
// Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
|
|
1408
1489
|
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
1409
1490
|
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
@@ -1415,7 +1496,7 @@ export async function runFleetDaemon() {
|
|
|
1415
1496
|
// Stop workers whose agent left the roster (removed in the app).
|
|
1416
1497
|
for (const [id, w] of [...workers]) {
|
|
1417
1498
|
if (!rosterIds.has(id)) {
|
|
1418
|
-
warn(`${w.label} removed — stopping it now
|
|
1499
|
+
warn(`${w.label} removed — stopping it now.`);
|
|
1419
1500
|
w.state.alive = false;
|
|
1420
1501
|
// Immediate teardown (Q6=B): kill the in-flight Claude process now; its
|
|
1421
1502
|
// task was already requeued server-side on removal.
|
|
@@ -1429,10 +1510,16 @@ export async function runFleetDaemon() {
|
|
|
1429
1510
|
} catch {
|
|
1430
1511
|
/* best-effort */
|
|
1431
1512
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1513
|
+
// Only poll mode's per-lane tree dies with the lane. A live lane owns no
|
|
1514
|
+
// checkout: the task it was building has its own, which must SURVIVE —
|
|
1515
|
+
// removing a lane requeues its task, and the next lane to pick that task
|
|
1516
|
+
// up resumes in that same directory rather than starting over.
|
|
1517
|
+
if (w.wt) {
|
|
1518
|
+
try {
|
|
1519
|
+
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
1520
|
+
} catch {
|
|
1521
|
+
/* best-effort */
|
|
1522
|
+
}
|
|
1436
1523
|
}
|
|
1437
1524
|
workers.delete(id);
|
|
1438
1525
|
tokenByAgent.delete(id);
|
package/bin/lib/git.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
import { rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
4
8
|
|
|
5
9
|
export function git(args, cwd) {
|
|
6
10
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
@@ -113,6 +117,149 @@ export function baseBranchName(baseRef) {
|
|
|
113
117
|
return String(baseRef || '').replace(/^origin\//, '') || 'main';
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
|
|
122
|
+
*
|
|
123
|
+
* Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
|
|
124
|
+
* ALREADY existed is one somebody was mid-way through, and the caller must not
|
|
125
|
+
* reset it; a freshly created one is at base by construction and has nothing to
|
|
126
|
+
* preserve. That single bit replaces the in-memory `resuming` flag and the
|
|
127
|
+
* on-disk task marker for the common case, because once a worktree is named
|
|
128
|
+
* after its task, "does this directory exist" IS "am I resuming".
|
|
129
|
+
*
|
|
130
|
+
* The prune-and-retry is not paranoia: `git worktree add` refuses a path that
|
|
131
|
+
* is still REGISTERED even when the directory is gone (`flowviant clean` rm's
|
|
132
|
+
* the dirs, `git worktree list` keeps the stale entries), and that failure is
|
|
133
|
+
* permanent until pruned.
|
|
134
|
+
*/
|
|
135
|
+
export function ensureWorktree(repoRoot, wt, ref) {
|
|
136
|
+
// Resolve first. `existsSync` answers relative to THIS process's cwd while
|
|
137
|
+
// `git worktree add` answers relative to repoRoot, so a relative path makes
|
|
138
|
+
// the two disagree: the check says "not there", the add says "already
|
|
139
|
+
// exists", and the prune-and-retry can't fix a path that was never the one
|
|
140
|
+
// we looked at. Callers pass absolute paths today; this makes that not matter.
|
|
141
|
+
wt = resolve(wt);
|
|
142
|
+
if (existsSync(wt)) return { path: wt, fresh: false };
|
|
143
|
+
try {
|
|
144
|
+
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
145
|
+
} catch {
|
|
146
|
+
git(['worktree', 'prune'], repoRoot);
|
|
147
|
+
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
148
|
+
}
|
|
149
|
+
return { path: wt, fresh: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
|
|
153
|
+
//
|
|
154
|
+
// A task's uncommitted work used to exist in exactly one place — a directory on
|
|
155
|
+
// whichever machine claimed it. That made the checkout precious: losing the box
|
|
156
|
+
// lost the work, so a task was pinned to a host, the host had to be named in the
|
|
157
|
+
// UI, and a container could never be thrown away. Pushing the work somewhere
|
|
158
|
+
// durable inverts all of that. The sandbox becomes a cache.
|
|
159
|
+
//
|
|
160
|
+
// These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
|
|
161
|
+
// are machine state, not history, and they must never appear in a PR, a branch
|
|
162
|
+
// listing, or anyone's `git log`. Force-pushed, because only the latest matters.
|
|
163
|
+
|
|
164
|
+
const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
|
|
165
|
+
|
|
166
|
+
/** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
|
|
167
|
+
* can't assume the machine has configured. */
|
|
168
|
+
function gitWithEnv(args, cwd, extraEnv) {
|
|
169
|
+
return execFileSync('git', args, {
|
|
170
|
+
cwd,
|
|
171
|
+
encoding: 'utf8',
|
|
172
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
173
|
+
env: { ...process.env, ...extraEnv },
|
|
174
|
+
}).trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Snapshot everything in the worktree — staged, unstaged and untracked — and
|
|
179
|
+
* push it, WITHOUT touching the agent's HEAD, index or files.
|
|
180
|
+
*
|
|
181
|
+
* That constraint is why this doesn't just commit. The agent is a live process
|
|
182
|
+
* with its own git intentions; committing under it would rewrite state it is
|
|
183
|
+
* mid-way through reasoning about, and `git stash` would rip the files out from
|
|
184
|
+
* under an editor. Building the tree in a throwaway index leaves the agent's
|
|
185
|
+
* world untouched — it cannot tell this happened.
|
|
186
|
+
*
|
|
187
|
+
* Returns the commit sha, or null if there was nothing dirty / no remote.
|
|
188
|
+
*/
|
|
189
|
+
export function checkpointWip(wt, intentId, baseRef) {
|
|
190
|
+
if (!isSafePathSegment(intentId)) return null;
|
|
191
|
+
const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
|
|
192
|
+
const env = {
|
|
193
|
+
GIT_INDEX_FILE: idx,
|
|
194
|
+
// A snapshot must never fail because the machine has no user.name — this is
|
|
195
|
+
// ours, not the user's, and it never lands in history anyone reads.
|
|
196
|
+
GIT_AUTHOR_NAME: 'Flowviant',
|
|
197
|
+
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
198
|
+
GIT_COMMITTER_NAME: 'Flowviant',
|
|
199
|
+
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
const head = git(['rev-parse', 'HEAD'], wt);
|
|
203
|
+
gitWithEnv(['read-tree', head], wt, env);
|
|
204
|
+
gitWithEnv(['add', '-A'], wt, env);
|
|
205
|
+
const tree = gitWithEnv(['write-tree'], wt, env);
|
|
206
|
+
// Nothing changed since HEAD — no snapshot worth pushing.
|
|
207
|
+
if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
|
|
208
|
+
const commit = gitWithEnv(
|
|
209
|
+
['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
|
|
210
|
+
wt,
|
|
211
|
+
env
|
|
212
|
+
);
|
|
213
|
+
git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
|
|
214
|
+
return commit;
|
|
215
|
+
} catch {
|
|
216
|
+
// Offline, no push rights, a repo with no origin — a checkpoint is an
|
|
217
|
+
// optimisation, never a reason to fail a task.
|
|
218
|
+
return null;
|
|
219
|
+
} finally {
|
|
220
|
+
try {
|
|
221
|
+
rmSync(idx, { force: true });
|
|
222
|
+
} catch {
|
|
223
|
+
/* best-effort */
|
|
224
|
+
}
|
|
225
|
+
void baseRef;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Rebuild a worktree from its last pushed checkpoint. Returns true if one was
|
|
231
|
+
* found and applied.
|
|
232
|
+
*
|
|
233
|
+
* The reset is MIXED on purpose: it leaves the snapshot's content in the files
|
|
234
|
+
* with HEAD back at the parent, which is what the agent had before — dirty
|
|
235
|
+
* working tree, nothing staged it didn't stage itself. A soft reset would hand
|
|
236
|
+
* it a fully-staged index it never created.
|
|
237
|
+
*/
|
|
238
|
+
export function restoreWip(wt, intentId) {
|
|
239
|
+
if (!isSafePathSegment(intentId)) return false;
|
|
240
|
+
const ref = wipRef(intentId);
|
|
241
|
+
try {
|
|
242
|
+
git(['fetch', 'origin', `+${ref}:${ref}`], wt);
|
|
243
|
+
const commit = git(['rev-parse', ref], wt);
|
|
244
|
+
const parent = git(['rev-parse', `${commit}^`], wt);
|
|
245
|
+
git(['checkout', '--detach', commit], wt);
|
|
246
|
+
git(['reset', parent], wt);
|
|
247
|
+
return true;
|
|
248
|
+
} catch {
|
|
249
|
+
return false; // no checkpoint for this task, or it's unreachable
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Drop a task's checkpoint once its work has landed somewhere real. */
|
|
254
|
+
export function clearWip(wt, intentId) {
|
|
255
|
+
if (!isSafePathSegment(intentId)) return;
|
|
256
|
+
try {
|
|
257
|
+
git(['push', 'origin', '--delete', wipRef(intentId)], wt);
|
|
258
|
+
} catch {
|
|
259
|
+
/* already gone, or no remote */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
116
263
|
export function resetWorktree(wt, baseRef) {
|
|
117
264
|
try {
|
|
118
265
|
git(['fetch', 'origin', '--quiet'], wt);
|
package/bin/lib/live.mjs
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* the human's answer and injects it to resume in place). Same session = the
|
|
8
8
|
* iterating loop, hosted through Flowviant.
|
|
9
9
|
*
|
|
10
|
-
* Auth
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Auth: whatever this machine's Claude Code is signed in with. The daemon used
|
|
11
|
+
* to strip ANTHROPIC_API_KEY so a stray key couldn't divert a laptop's turns to
|
|
12
|
+
* API billing; on a machine the project leaves running an org key is the
|
|
13
|
+
* intended credential, and which one is legitimate is between the operator and
|
|
14
|
+
* Anthropic — not something Flowviant detects or enforces.
|
|
13
15
|
*
|
|
14
16
|
* NOTE: the SDK mechanics here (streaming-input continuity, tool_use visibility,
|
|
15
17
|
* one result per turn) are validated by spikes; the end-to-end task loop needs a
|
|
@@ -33,7 +35,14 @@ import {
|
|
|
33
35
|
} from './config.mjs';
|
|
34
36
|
import { c, info, ok, warn } from './ui.mjs';
|
|
35
37
|
import { sleep } from './claude.mjs';
|
|
36
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
git,
|
|
40
|
+
resetWorktree,
|
|
41
|
+
isValidBranch,
|
|
42
|
+
checkpointWip,
|
|
43
|
+
restoreWip,
|
|
44
|
+
clearWip,
|
|
45
|
+
} from './git.mjs';
|
|
37
46
|
import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
38
47
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
39
48
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
@@ -48,6 +57,11 @@ const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
|
|
|
48
57
|
// missed heartbeats.
|
|
49
58
|
const PREVIEW_TTL_MINUTES = 6;
|
|
50
59
|
const PREVIEW_HEARTBEAT_MS = 90_000;
|
|
60
|
+
// How often a running task snapshots its uncommitted work to the remote. Two
|
|
61
|
+
// minutes bounds what an unannounced death can cost while staying invisible:
|
|
62
|
+
// an unchanged tree writes no commit and pushes nothing, so an agent that is
|
|
63
|
+
// thinking rather than editing costs one cheap tree comparison.
|
|
64
|
+
const CHECKPOINT_MS = 120_000;
|
|
51
65
|
async function registerLiveTarget(intentId, kind, url) {
|
|
52
66
|
try {
|
|
53
67
|
await fetch(LIVE_TARGET_URL, {
|
|
@@ -544,12 +558,13 @@ async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchB
|
|
|
544
558
|
export async function runLiveTask({
|
|
545
559
|
mcpUrl,
|
|
546
560
|
token,
|
|
547
|
-
|
|
561
|
+
worktreeFor,
|
|
548
562
|
baseRef,
|
|
549
563
|
repoRoot,
|
|
550
564
|
isAlive,
|
|
551
565
|
resumeIntentId,
|
|
552
566
|
onChild,
|
|
567
|
+
onIntent,
|
|
553
568
|
}) {
|
|
554
569
|
const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
|
|
555
570
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
@@ -557,12 +572,29 @@ export async function runLiveTask({
|
|
|
557
572
|
const brief = claim.brief ?? {};
|
|
558
573
|
const title = brief.title ?? 'a task';
|
|
559
574
|
|
|
575
|
+
// THE SANDBOX BELONGS TO THE TASK, not to the lane that happened to pick it
|
|
576
|
+
// up. Worktrees used to be `agent-<agentId>` — one long-lived checkout per
|
|
577
|
+
// lane, wiped back to base between tasks — which is why a lane had to own
|
|
578
|
+
// anything at all, and why every claim had to work out whether the directory
|
|
579
|
+
// it was standing in held its own half-built work or somebody else's finished
|
|
580
|
+
// work. Keyed by intent, that question answers itself: the directory either
|
|
581
|
+
// exists (yours, mid-flight) or it doesn't (nothing to lose).
|
|
582
|
+
//
|
|
583
|
+
// Note this is the first point at which a worktree can be chosen — the claim
|
|
584
|
+
// is what tells us which task we're building, and the daemon has no business
|
|
585
|
+
// creating a checkout for work it hasn't been given.
|
|
586
|
+
// Name the task this lane is holding, so its memory can be attributed to a
|
|
587
|
+
// task rather than to an anonymous pid.
|
|
588
|
+
onIntent?.(intentId);
|
|
589
|
+
const { path: cwd, fresh: freshTree } = worktreeFor(intentId);
|
|
590
|
+
|
|
560
591
|
// Re-claiming the SAME intent this worker was just working — either this
|
|
561
|
-
// daemon's own memory (parked on a blocker, now resuming) or
|
|
562
|
-
//
|
|
563
|
-
// uncommitted work. Do NOT reset.
|
|
592
|
+
// daemon's own memory (parked on a blocker, now resuming) or a worktree that
|
|
593
|
+
// was already on disk (the daemon restarted mid-task). Either way it holds
|
|
594
|
+
// hours of uncommitted work. Do NOT reset. `!fresh` subsumes what the task
|
|
595
|
+
// marker used to tell us, since the directory is now named after the task.
|
|
564
596
|
const resuming = !!resumeIntentId && intentId === resumeIntentId;
|
|
565
|
-
const resumedInPlace = !resuming &&
|
|
597
|
+
const resumedInPlace = !resuming && !freshTree;
|
|
566
598
|
|
|
567
599
|
// CONSENT. A patch writes commits into the working checkout of whoever runs
|
|
568
600
|
// this daemon — chosen by a model, and triggerable by any teammate who
|
|
@@ -656,6 +688,20 @@ export async function runLiveTask({
|
|
|
656
688
|
}
|
|
657
689
|
if (!stacked) resetWorktree(cwd, baseRef);
|
|
658
690
|
}
|
|
691
|
+
// A fresh checkout is not necessarily a fresh TASK. This machine may never
|
|
692
|
+
// have seen this intent while another one built on it for an hour before
|
|
693
|
+
// dying, being released, or simply being a different container — and that
|
|
694
|
+
// work is on the remote. Restoring here, AFTER the resets above, is what
|
|
695
|
+
// makes a sandbox a cache rather than the only copy: any machine can pick up
|
|
696
|
+
// any task exactly where it was left.
|
|
697
|
+
if (freshTree && restoreWip(cwd, intentId)) {
|
|
698
|
+
info(`${c.dim('restored work in progress from the last checkpoint')}`);
|
|
699
|
+
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
700
|
+
runId,
|
|
701
|
+
turnId: `restore:${runId}`,
|
|
702
|
+
text: 'Picked this up on another machine — restored the work in progress from its last checkpoint.',
|
|
703
|
+
}).catch(() => {});
|
|
704
|
+
}
|
|
659
705
|
materializeInto(cwd); // resets wipe the synced env files — rewrite them
|
|
660
706
|
writeTaskMarker(cwd, intentId);
|
|
661
707
|
|
|
@@ -669,14 +715,11 @@ export async function runLiveTask({
|
|
|
669
715
|
}).catch(() => {});
|
|
670
716
|
}
|
|
671
717
|
|
|
718
|
+
// The machine's own credentials, inherited as configured. See claude.mjs for
|
|
719
|
+
// why this no longer strips ANTHROPIC_API_KEY / AUTH_TOKEN / BASE_URL: on a
|
|
720
|
+
// machine the project leaves running, an org key is the intended credential
|
|
721
|
+
// and deleting it overrides the operator. Enforcement is Anthropic's.
|
|
672
722
|
const env = { ...process.env };
|
|
673
|
-
// Force the user's Claude Code subscription — strip EVERY var that could
|
|
674
|
-
// divert to API billing or a proxy (poll mode strips these too; live mode
|
|
675
|
-
// was only clearing API_KEY, so an exported AUTH_TOKEN/BASE_URL silently
|
|
676
|
-
// billed the API on the default path).
|
|
677
|
-
delete env.ANTHROPIC_API_KEY;
|
|
678
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
679
|
-
delete env.ANTHROPIC_BASE_URL;
|
|
680
723
|
|
|
681
724
|
// The conversation arrives WITH the brief (0.30.0) — the claim already read
|
|
682
725
|
// it, so asking again over poll_channel was a round-trip that told us nothing
|
|
@@ -695,6 +738,21 @@ export async function runLiveTask({
|
|
|
695
738
|
let afterId =
|
|
696
739
|
brief.lastMessageId ?? (priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null);
|
|
697
740
|
|
|
741
|
+
// Checkpoint on a timer for the whole session. Not on turn boundaries: the
|
|
742
|
+
// expensive-to-lose states are the ones that arrive without a boundary — the
|
|
743
|
+
// box is killed, the container is reclaimed, the process is OOMed — and a
|
|
744
|
+
// long tool-running turn is exactly when the most uncommitted work exists.
|
|
745
|
+
// Cheap when idle: an unchanged tree writes no commit and pushes nothing.
|
|
746
|
+
let landed = false; // set when the work reaches a branch/PR/patch — see finally
|
|
747
|
+
const checkpointTimer = setInterval(() => {
|
|
748
|
+
try {
|
|
749
|
+
checkpointWip(cwd, intentId);
|
|
750
|
+
} catch {
|
|
751
|
+
/* never let a snapshot disturb a running task */
|
|
752
|
+
}
|
|
753
|
+
}, CHECKPOINT_MS);
|
|
754
|
+
checkpointTimer.unref?.();
|
|
755
|
+
|
|
698
756
|
const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
|
|
699
757
|
const session = query({
|
|
700
758
|
prompt: input.stream(),
|
|
@@ -814,6 +872,7 @@ export async function runLiveTask({
|
|
|
814
872
|
if (isPatch) {
|
|
815
873
|
await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
|
|
816
874
|
}
|
|
875
|
+
landed = true;
|
|
817
876
|
return { outcome: 'done', title, intentId };
|
|
818
877
|
}
|
|
819
878
|
|
|
@@ -838,12 +897,27 @@ export async function runLiveTask({
|
|
|
838
897
|
}).catch(() => null);
|
|
839
898
|
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
840
899
|
// server killed this run — abandon the session, don't keep building.
|
|
900
|
+
// RELEASED: stop, and touch nothing. The human freed the machine, not
|
|
901
|
+
// the work — the branch, the PR and the worktree all stay exactly as
|
|
902
|
+
// they are, and re-@mentioning resumes here rather than from base. The
|
|
903
|
+
// finally block takes a last checkpoint on the way out, so even the
|
|
904
|
+
// uncommitted edits survive to whichever machine picks it up next.
|
|
905
|
+
if (poll && poll.ok === false && poll.released) {
|
|
906
|
+
return { outcome: 'released', title, intentId };
|
|
907
|
+
}
|
|
841
908
|
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
842
|
-
// Discarded (restart/reassign)
|
|
843
|
-
//
|
|
844
|
-
//
|
|
909
|
+
// Discarded (restart/reassign). REMOVE the checkout rather than reset
|
|
910
|
+
// it: the directory is named after the intent, so a restart of this
|
|
911
|
+
// same task would otherwise find it, read "already exists" as "I am
|
|
912
|
+
// resuming", and pick the abandoned attempt back up — the precise
|
|
913
|
+
// failure the old marker-clearing existed to prevent. Deleting it
|
|
914
|
+
// makes the next claim genuinely fresh.
|
|
845
915
|
clearTaskMarker(cwd);
|
|
846
|
-
|
|
916
|
+
try {
|
|
917
|
+
git(['worktree', 'remove', '--force', cwd], repoRoot);
|
|
918
|
+
} catch {
|
|
919
|
+
resetWorktree(cwd, baseRef); // couldn't remove it — at least empty it
|
|
920
|
+
}
|
|
847
921
|
return { outcome: 'torn_down', title, intentId };
|
|
848
922
|
}
|
|
849
923
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
@@ -884,6 +958,7 @@ export async function runLiveTask({
|
|
|
884
958
|
return { outcome: 'stalled', title, intentId };
|
|
885
959
|
}
|
|
886
960
|
}
|
|
961
|
+
landed = completed;
|
|
887
962
|
return { outcome: completed ? 'done' : 'stalled', title, intentId };
|
|
888
963
|
} catch (e) {
|
|
889
964
|
const rl = classifyRateLimit(e);
|
|
@@ -896,7 +971,21 @@ export async function runLiveTask({
|
|
|
896
971
|
}
|
|
897
972
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
898
973
|
} finally {
|
|
974
|
+
clearInterval(checkpointTimer);
|
|
975
|
+
// Last word on this task's state. If the work landed (branch pushed, PR
|
|
976
|
+
// open, patch applied) the checkpoint has served its purpose and the ref is
|
|
977
|
+
// deleted — otherwise it accumulates one hidden ref per task, forever, on
|
|
978
|
+
// everyone's remote. If it did NOT land, this is the most important
|
|
979
|
+
// checkpoint of the run: it is the one taken as the task parks, is released,
|
|
980
|
+
// hits a usage limit, or dies.
|
|
981
|
+
try {
|
|
982
|
+
if (landed) clearWip(cwd, intentId);
|
|
983
|
+
else checkpointWip(cwd, intentId);
|
|
984
|
+
} catch {
|
|
985
|
+
/* teardown must not throw */
|
|
986
|
+
}
|
|
899
987
|
onChild?.(null); // no longer busy — token may rotate between tasks
|
|
988
|
+
onIntent?.(null);
|
|
900
989
|
input.close();
|
|
901
990
|
try {
|
|
902
991
|
await session.interrupt?.();
|
|
@@ -943,7 +1032,10 @@ function copyLocalEnvFiles(repoRoot, worktree, log) {
|
|
|
943
1032
|
export async function runLiveWorker({
|
|
944
1033
|
agentId,
|
|
945
1034
|
label,
|
|
946
|
-
|
|
1035
|
+
// `(intentId) => { path, fresh }`. A lane no longer HAS a working directory —
|
|
1036
|
+
// it is a credential and nothing else. Every checkout belongs to a task, so
|
|
1037
|
+
// the worker asks for one only once it knows which task it is holding.
|
|
1038
|
+
worktreeFor,
|
|
947
1039
|
baseRef,
|
|
948
1040
|
repoRoot,
|
|
949
1041
|
getToken,
|
|
@@ -952,6 +1044,7 @@ export async function runLiveWorker({
|
|
|
952
1044
|
isAlive,
|
|
953
1045
|
onTokenSuspect,
|
|
954
1046
|
onChild,
|
|
1047
|
+
onIntent,
|
|
955
1048
|
onPreview,
|
|
956
1049
|
}) {
|
|
957
1050
|
// The intent this worker is holding across iterations. When a task parks on a
|
|
@@ -1000,6 +1093,12 @@ export async function runLiveWorker({
|
|
|
1000
1093
|
};
|
|
1001
1094
|
const startReviewPreview = async (intentId) => {
|
|
1002
1095
|
stopPreview();
|
|
1096
|
+
if (!intentId) return;
|
|
1097
|
+
// The finished task's own worktree — already on disk, so this is a lookup,
|
|
1098
|
+
// not a creation. A review preview serves the branch that was just built,
|
|
1099
|
+
// which now has a durable home instead of living in whichever lane's
|
|
1100
|
+
// checkout happened to run it (and being wiped by that lane's next task).
|
|
1101
|
+
const { path: cwd } = worktreeFor(intentId);
|
|
1003
1102
|
const cfg = loadPreviewConfig(cwd);
|
|
1004
1103
|
const kind = cfg?.ui ? 'ui' : cfg?.api ? 'api' : null;
|
|
1005
1104
|
const entry = kind ? cfg[kind] : null;
|
|
@@ -1090,9 +1189,10 @@ export async function runLiveWorker({
|
|
|
1090
1189
|
let res;
|
|
1091
1190
|
try {
|
|
1092
1191
|
res = await runLiveTask({
|
|
1192
|
+
onIntent,
|
|
1093
1193
|
mcpUrl: getMcpUrl() ?? MCP_URL,
|
|
1094
1194
|
token,
|
|
1095
|
-
|
|
1195
|
+
worktreeFor,
|
|
1096
1196
|
baseRef,
|
|
1097
1197
|
repoRoot,
|
|
1098
1198
|
isAlive,
|
|
@@ -1133,6 +1233,18 @@ export async function runLiveWorker({
|
|
|
1133
1233
|
phase = '';
|
|
1134
1234
|
continue;
|
|
1135
1235
|
}
|
|
1236
|
+
if (res.outcome === 'released') {
|
|
1237
|
+
// Released: the human wanted the machine back, not the work undone. The
|
|
1238
|
+
// session is already gone (the finally checkpointed on the way out) and
|
|
1239
|
+
// the worktree stays untouched, so a later @mention resumes here rather
|
|
1240
|
+
// than from base. Clear lastIntentId so this worker doesn't treat a
|
|
1241
|
+
// future claim of the same task as its own in-memory resume — the
|
|
1242
|
+
// on-disk checkout is the resume signal now, and it may well be a
|
|
1243
|
+
// different machine that picks this up.
|
|
1244
|
+
info(`${label} ${c.dim(`"${res.title}" was released — stopped; its work is kept`)}`);
|
|
1245
|
+
phase = '';
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1136
1248
|
if (res.outcome === 'parked') {
|
|
1137
1249
|
// Idle-parked too long on a blocker: we freed the Claude process. The intent
|
|
1138
1250
|
// stays claimed; a later poll re-claims + resumes (with transcript) once the
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this machine is actually doing with itself.
|
|
3
|
+
*
|
|
4
|
+
* Flowviant can tell you a task is building and nothing about why it is slow.
|
|
5
|
+
* That gap is affordable when the machine is your own laptop — you can look at
|
|
6
|
+
* it — and not when the machine is a box in a rack that four people share. The
|
|
7
|
+
* whole argument for one central machine is that one machine is easier to
|
|
8
|
+
* manage than N laptops, and that is only true if you can SEE the one machine.
|
|
9
|
+
*
|
|
10
|
+
* This is deliberately telemetry, never a budget. It reports pressure that
|
|
11
|
+
* exists right now, which is the same category as "what is building" — a fact
|
|
12
|
+
* about the world you would have known by sitting at the keyboard. It must
|
|
13
|
+
* never become a headroom number in front of the person dispatching: "you may
|
|
14
|
+
* run 2 more tasks" is the capacity dial wearing a lab coat, and that is dead.
|
|
15
|
+
* The only surface for this is project settings, whose audience is whoever
|
|
16
|
+
* administers the box.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync, statfsSync } from 'node:fs';
|
|
20
|
+
import { freemem, loadavg } from 'node:os';
|
|
21
|
+
import { MACHINE } from './config.mjs';
|
|
22
|
+
|
|
23
|
+
const readFile = (p) => {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(p, 'utf8').trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Memory in use, from the cgroup when there is one.
|
|
33
|
+
*
|
|
34
|
+
* `freemem()` reports the HOST inside a container — the same trap that made
|
|
35
|
+
* `cpus().length` lie about core count — so a container at 95% of its own
|
|
36
|
+
* limit looks idle if you ask the os module. Prefer memory.current against
|
|
37
|
+
* memory.max, and fall back only when there is no cgroup to read.
|
|
38
|
+
*/
|
|
39
|
+
function memoryUsed() {
|
|
40
|
+
const cur = readFile('/sys/fs/cgroup/memory.current');
|
|
41
|
+
if (cur !== null) {
|
|
42
|
+
const n = Number(cur);
|
|
43
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
44
|
+
}
|
|
45
|
+
return Math.max(0, MACHINE.memBytes - freemem());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resident memory of a process AND everything it spawned.
|
|
50
|
+
*
|
|
51
|
+
* A task is never one process: it is Claude, plus a dev server, plus whatever
|
|
52
|
+
* the test runner forked. Charging a task only its own RSS would report a few
|
|
53
|
+
* hundred megabytes for something holding twelve gigabytes, which is worse than
|
|
54
|
+
* reporting nothing — it would exonerate the exact task you are hunting.
|
|
55
|
+
*
|
|
56
|
+
* Linux only, and that is stated rather than hidden: /proc is how you read this
|
|
57
|
+
* honestly, and a wrong number here sends someone to kill the wrong task.
|
|
58
|
+
*/
|
|
59
|
+
export function processTreeRssBytes(pid) {
|
|
60
|
+
if (!pid) return null;
|
|
61
|
+
const rssOf = (p) => {
|
|
62
|
+
const roll = readFile(`/proc/${p}/smaps_rollup`);
|
|
63
|
+
const src = roll ?? readFile(`/proc/${p}/status`);
|
|
64
|
+
if (!src) return 0;
|
|
65
|
+
const m = src.match(/^(?:Rss|VmRSS):\s+(\d+)\s+kB/m);
|
|
66
|
+
return m ? Number(m[1]) * 1024 : 0;
|
|
67
|
+
};
|
|
68
|
+
const childrenOf = (p) => {
|
|
69
|
+
const t = readFile(`/proc/${p}/task`);
|
|
70
|
+
if (t === null && !readFile(`/proc/${p}/stat`)) return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
try {
|
|
73
|
+
for (const tid of readdirSync(`/proc/${p}/task`)) {
|
|
74
|
+
const kids = readFile(`/proc/${p}/task/${tid}/children`);
|
|
75
|
+
if (kids) out.push(...kids.split(/\s+/).filter(Boolean).map(Number));
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* no children file (not Linux, or the process just exited) */
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
let total = 0;
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const stack = [Number(pid)];
|
|
85
|
+
while (stack.length) {
|
|
86
|
+
const p = stack.pop();
|
|
87
|
+
if (!Number.isFinite(p) || seen.has(p)) continue;
|
|
88
|
+
seen.add(p);
|
|
89
|
+
total += rssOf(p);
|
|
90
|
+
stack.push(...childrenOf(p));
|
|
91
|
+
}
|
|
92
|
+
return total || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Free bytes on the volume holding the worktrees. */
|
|
96
|
+
export function diskFreeBytes(path) {
|
|
97
|
+
try {
|
|
98
|
+
// statfsSync landed in Node 18.15; the daemon's floor is well past that.
|
|
99
|
+
const s = statfsSync(path);
|
|
100
|
+
return { free: s.bavail * s.bsize, total: s.blocks * s.bsize };
|
|
101
|
+
} catch {
|
|
102
|
+
return null; // no statfs on this platform, or the path is gone
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A snapshot for the roster poll. Small, flat, and all of it observed — nothing
|
|
108
|
+
* here is a prediction or an allowance.
|
|
109
|
+
*/
|
|
110
|
+
export function machineSnapshot({ worktreeDir, tasks = [] } = {}) {
|
|
111
|
+
const used = memoryUsed();
|
|
112
|
+
const disk = worktreeDir ? diskFreeBytes(worktreeDir) : null;
|
|
113
|
+
return {
|
|
114
|
+
memTotal: MACHINE.memBytes,
|
|
115
|
+
memUsed: used,
|
|
116
|
+
cores: MACHINE.cores,
|
|
117
|
+
// Unix only; Windows reports zeroes, which we send as null rather than as a
|
|
118
|
+
// very calm-looking 0.00.
|
|
119
|
+
load1: loadavg()[0] || null,
|
|
120
|
+
diskFree: disk?.free ?? null,
|
|
121
|
+
diskTotal: disk?.total ?? null,
|
|
122
|
+
// Per-task, so "the box is full" can be traced to the task that filled it.
|
|
123
|
+
tasks: tasks
|
|
124
|
+
.map((t) => ({ intentId: t.intentId, rss: processTreeRssBytes(t.pid) }))
|
|
125
|
+
.filter((t) => t.intentId && t.rss),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|