flowviant 0.34.2 → 0.36.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 +33 -11
- package/bin/lib/config.mjs +55 -11
- package/bin/lib/fleet.mjs +148 -40
- 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
|
@@ -89,8 +89,18 @@ export const RESUME =
|
|
|
89
89
|
'Resume. First call get_blocker_resolution for any blocker you reported; if resolved, ' +
|
|
90
90
|
'apply the human’s answer and continue. Otherwise keep picking up and completing ' +
|
|
91
91
|
'the tasks you were @mentioned on, per your instructions.';
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// `intentId` is the task the SERVER says this lane is next in line for. Naming
|
|
93
|
+
// it matters beyond saving a lookup: the daemon has already spawned this Claude
|
|
94
|
+
// with that task's --model and --effort, and those cannot change once the
|
|
95
|
+
// process exists. Left to pick freely, a lane could claim a sibling task and
|
|
96
|
+
// run it under settings its owner chose for something else. Omitted (older
|
|
97
|
+
// server, or nothing waiting) it falls back to the original free pick.
|
|
98
|
+
export const SINGLE_KICKOFF = (intentId) =>
|
|
99
|
+
intentId
|
|
100
|
+
? `Pick up Flowviant task ${intentId} — call claim_next_intent with intentId "${intentId}" — ` +
|
|
101
|
+
'complete exactly that ONE task per your instructions, then stop. If that ' +
|
|
102
|
+
'claim comes back unavailable, claim whatever is next for you instead.'
|
|
103
|
+
: 'Pick up and complete exactly ONE Flowviant task per your instructions, then stop.';
|
|
94
104
|
export const SINGLE_RESUME =
|
|
95
105
|
'Resume your current task. Call get_blocker_resolution for the blocker you reported; ' +
|
|
96
106
|
'if resolved, apply the human’s answer and finish this one intent, then stop.';
|
|
@@ -590,7 +600,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
|
|
|
590
600
|
// returned string for sentinel detection, and each activity is handed to
|
|
591
601
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
592
602
|
// off and keep the raw text passthrough + line sentinels.
|
|
593
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm, readOnly }) {
|
|
603
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, model, effort }) {
|
|
594
604
|
return new Promise((resolve) => {
|
|
595
605
|
const args = [];
|
|
596
606
|
if (resume) args.push('--continue');
|
|
@@ -599,17 +609,29 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
599
609
|
if (mcpConfig) args.push('--mcp-config', mcpConfig);
|
|
600
610
|
// Pin the model — never inherit the user's global default (which may be a
|
|
601
611
|
// 1M/long-context tier their subscription can't bill autonomous work on).
|
|
602
|
-
|
|
612
|
+
// A per-task override (chosen in the app, validated server-side against a
|
|
613
|
+
// fixed list before it ever reaches this argv) wins over the machine pin;
|
|
614
|
+
// absent, the pin stands. Effort has no machine-level pin at all: unset
|
|
615
|
+
// means Claude Code's own default, which is the honest resting state.
|
|
616
|
+
args.push('--model', model || MODEL);
|
|
617
|
+
if (effort) args.push('--effort', effort);
|
|
603
618
|
if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
|
|
604
619
|
// readOnly wins over wikiPerm: a consult must never inherit write tools.
|
|
605
620
|
args.push(...(readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM));
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
621
|
+
// Whatever this machine is signed in with, we use. We do NOT pick.
|
|
622
|
+
//
|
|
623
|
+
// This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
|
|
624
|
+
// the subscription path, which was right when the daemon ran on a
|
|
625
|
+
// developer's laptop: a key left in their shell would silently bill every
|
|
626
|
+
// turn as raw API usage instead of the plan they were already paying for.
|
|
627
|
+
// On a machine the project leaves running, an inherited org key is the
|
|
628
|
+
// POINT — deleting it is the daemon overriding the credential its operator
|
|
629
|
+
// deliberately configured.
|
|
630
|
+
//
|
|
631
|
+
// Which credential is correct, and whether an account may be shared, is
|
|
632
|
+
// between the operator and Anthropic. Flowviant does not detect it and does
|
|
633
|
+
// not enforce it; it runs Claude the ordinary way and relays what happens.
|
|
634
|
+
const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
613
635
|
onSpawn?.(child);
|
|
614
636
|
let out = '';
|
|
615
637
|
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);
|
|
@@ -122,7 +124,7 @@ async function fetchRoster(haveIds) {
|
|
|
122
124
|
|
|
123
125
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
124
126
|
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
125
|
-
async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
|
|
127
|
+
async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
|
|
126
128
|
let resuming = false;
|
|
127
129
|
let needsReset = true; // reset to base before a FRESH task, not on idle polls
|
|
128
130
|
let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
|
|
@@ -152,15 +154,25 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
152
154
|
needsReset = false;
|
|
153
155
|
}
|
|
154
156
|
const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
|
|
157
|
+
// The task the server says is next for this lane, read ONCE per turn: the
|
|
158
|
+
// model and effort below become process flags, so they must describe the
|
|
159
|
+
// same task the kickoff tells Claude to claim. Re-reading the map mid-turn
|
|
160
|
+
// could pair one task's flags with another's work.
|
|
161
|
+
const next = resuming ? null : getNext?.(agentId) || null;
|
|
155
162
|
let out = '';
|
|
156
163
|
try {
|
|
157
164
|
out = await runTurn({
|
|
158
|
-
prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF,
|
|
165
|
+
prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF(next?.intentId),
|
|
159
166
|
resume: resuming,
|
|
160
167
|
system: SYSTEM_SINGLE,
|
|
161
168
|
cwd,
|
|
162
169
|
mcpConfig,
|
|
163
170
|
label,
|
|
171
|
+
// Per-task overrides — null/absent means this machine's own defaults
|
|
172
|
+
// (FLOWVIANT_MODEL, and Claude Code's own effort). A resume keeps the
|
|
173
|
+
// session it already has, so there is nothing to re-pick there.
|
|
174
|
+
model: next?.model || undefined,
|
|
175
|
+
effort: next?.effort || undefined,
|
|
164
176
|
onSpawn: (ch) => onChild?.(ch),
|
|
165
177
|
});
|
|
166
178
|
} finally {
|
|
@@ -227,11 +239,31 @@ export async function runFleetDaemon() {
|
|
|
227
239
|
reapOrphanPreviews((m) => info(m));
|
|
228
240
|
|
|
229
241
|
// 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.
|
|
242
|
+
// so Ctrl+C mid-task never loses local work. Keyed per repo path.
|
|
232
243
|
const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
|
|
233
244
|
const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
|
|
234
245
|
mkdirSync(baseDir, { recursive: true });
|
|
246
|
+
|
|
247
|
+
// ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
|
|
248
|
+
// `agent-<agentId>` — a long-lived tree per lane, reset to base between
|
|
249
|
+
// tasks — and that was the last thing a lane owned. Now a lane is a
|
|
250
|
+
// credential and nothing more, which is what makes it disposable: the server
|
|
251
|
+
// can hand any lane any task, and two tasks can never be in each other's
|
|
252
|
+
// files even when one is mid-edit.
|
|
253
|
+
const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
|
|
254
|
+
const worktreeFor = (intentId) => {
|
|
255
|
+
const r = ensureWorktree(repoRoot, taskWorktreePath(intentId), baseRef);
|
|
256
|
+
// Only on creation: a resumed tree already has its env, and rewriting it
|
|
257
|
+
// mid-task would clobber anything the agent changed.
|
|
258
|
+
if (r.fresh) {
|
|
259
|
+
try {
|
|
260
|
+
materializeInto(r.path);
|
|
261
|
+
} catch {
|
|
262
|
+
/* best-effort — the task still builds, secrets-backed paths may 500 */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return r;
|
|
266
|
+
};
|
|
235
267
|
try {
|
|
236
268
|
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
237
269
|
if (kb > 1024)
|
|
@@ -241,9 +273,48 @@ export async function runFleetDaemon() {
|
|
|
241
273
|
} catch {
|
|
242
274
|
/* du unavailable (Windows) — skip the disk line */
|
|
243
275
|
}
|
|
276
|
+
|
|
277
|
+
// Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
|
|
278
|
+
// N directories, reused forever. Per-task trees are not: every task ever
|
|
279
|
+
// built leaves one behind, so without this the disk grows without bound and
|
|
280
|
+
// `flowviant clean` becomes a chore rather than a convenience.
|
|
281
|
+
//
|
|
282
|
+
// Age, not state, is the test. The daemon has no list of which intents are
|
|
283
|
+
// still open, and asking the server for one would put a delete behind a
|
|
284
|
+
// network call that can fail — so anything untouched for a fortnight goes,
|
|
285
|
+
// which is far beyond how long a task stays reviewable and far beyond any
|
|
286
|
+
// pause a human takes mid-build. Runs at startup only: mid-run this would
|
|
287
|
+
// race a worker that is quietly parked on a blocker.
|
|
288
|
+
try {
|
|
289
|
+
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
290
|
+
let reaped = 0;
|
|
291
|
+
for (const name of readdirSync(baseDir)) {
|
|
292
|
+
if (!name.startsWith('task-')) continue;
|
|
293
|
+
const p = join(baseDir, name);
|
|
294
|
+
try {
|
|
295
|
+
if (statSync(p).mtimeMs > cutoff) continue;
|
|
296
|
+
// Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
|
|
297
|
+
// a stale entry that blocks re-adding the same path later.
|
|
298
|
+
git(['worktree', 'remove', '--force', p], repoRoot);
|
|
299
|
+
reaped++;
|
|
300
|
+
} catch {
|
|
301
|
+
/* held, gone, or not ours — leave it for `flowviant clean` */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
|
|
305
|
+
} catch {
|
|
306
|
+
/* the worktree home may not exist yet on a first run */
|
|
307
|
+
}
|
|
244
308
|
const tokenByAgent = new Map(); // agentId -> latest worker token
|
|
245
309
|
const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
|
|
246
310
|
const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
|
|
311
|
+
// agentId -> the intent the server would hand this lane next: { intentId,
|
|
312
|
+
// title, model, effort }. `--model`/`--effort` are fixed when Claude starts,
|
|
313
|
+
// and by then nothing has been claimed — so the server names the task first
|
|
314
|
+
// and the turn pins its claim to that id. Absent on older servers, in which
|
|
315
|
+
// case the lane behaves exactly as it did before: generic kickoff, machine
|
|
316
|
+
// defaults.
|
|
317
|
+
const nextByAgent = new Map();
|
|
247
318
|
let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
|
|
248
319
|
let mcpUrl = MCP_URL;
|
|
249
320
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
@@ -486,6 +557,8 @@ export async function runFleetDaemon() {
|
|
|
486
557
|
};
|
|
487
558
|
|
|
488
559
|
const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
|
|
560
|
+
// Machine telemetry — what the box is doing with itself, for the admin view.
|
|
561
|
+
const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
|
|
489
562
|
const checkingPlans = new Set();
|
|
490
563
|
const processPlanCheckJobs = (jobs) => {
|
|
491
564
|
for (const job of jobs ?? []) {
|
|
@@ -561,16 +634,17 @@ export async function runFleetDaemon() {
|
|
|
561
634
|
let joinChain = Promise.resolve();
|
|
562
635
|
|
|
563
636
|
/** The worktree currently building this intent, or null if this machine isn't.
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
637
|
+
* Now a direct lookup rather than a scan: a task's checkout is named after
|
|
638
|
+
* the task, so there is exactly one place it could be. The marker is still
|
|
639
|
+
* consulted, but for LIFECYCLE rather than identity — a directory that
|
|
640
|
+
* outlived its run (finished, cleared its marker, kept for the review
|
|
641
|
+
* preview) exists but is not building anything, and must not take an edit. */
|
|
567
642
|
const worktreeBuilding = (intentId) => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
643
|
+
const wt = taskWorktreePath(intentId);
|
|
644
|
+
try {
|
|
645
|
+
if (existsSync(wt) && readTaskMarker(wt) === intentId) return { wt };
|
|
646
|
+
} catch {
|
|
647
|
+
/* a worktree that vanished isn't building anything */
|
|
574
648
|
}
|
|
575
649
|
return null;
|
|
576
650
|
};
|
|
@@ -1302,6 +1376,8 @@ export async function runFleetDaemon() {
|
|
|
1302
1376
|
mintedAt.set(a.agentId, Date.now());
|
|
1303
1377
|
}
|
|
1304
1378
|
hasWorkByAgent.set(a.agentId, !!a.hasWork);
|
|
1379
|
+
if (a.next && typeof a.next.intentId === 'string') nextByAgent.set(a.agentId, a.next);
|
|
1380
|
+
else nextByAgent.delete(a.agentId);
|
|
1305
1381
|
if (!workers.has(a.agentId)) {
|
|
1306
1382
|
// Local ceiling, enforced and not merely requested. The roster can carry
|
|
1307
1383
|
// more lanes than this machine asked for — someone added capacity by
|
|
@@ -1320,46 +1396,50 @@ export async function runFleetDaemon() {
|
|
|
1320
1396
|
}
|
|
1321
1397
|
continue;
|
|
1322
1398
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1399
|
+
// LIVE lanes get NO checkout of their own — they ask for one per task,
|
|
1400
|
+
// once they know which task. Poll mode is the legacy escape hatch and
|
|
1401
|
+
// keeps its per-lane tree; it predates per-task sandboxes and isn't
|
|
1402
|
+
// worth restructuring for a path nobody runs by default.
|
|
1403
|
+
let wt = null;
|
|
1404
|
+
if (!LIVE) {
|
|
1405
|
+
try {
|
|
1406
|
+
ensureWorktree(repoRoot, (wt = join(baseDir, `agent-${a.agentId}`)), baseRef);
|
|
1407
|
+
} catch (e) {
|
|
1408
|
+
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
try {
|
|
1412
|
+
materializeInto(wt); // synced env into the fresh worktree
|
|
1413
|
+
} catch {
|
|
1414
|
+
/* best-effort */
|
|
1334
1415
|
}
|
|
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
1416
|
}
|
|
1344
1417
|
const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
|
|
1345
1418
|
const label = colorFn(`[${a.name}]`);
|
|
1346
1419
|
const state = { alive: true, child: null };
|
|
1347
|
-
ok(`${label} ${c.dim(
|
|
1420
|
+
ok(`${label} ${c.dim(LIVE ? 'online — live session' : 'online — worktree ready')}`);
|
|
1348
1421
|
const workerFn = LIVE ? runLiveWorker : runFleetWorker;
|
|
1349
1422
|
const promise = workerFn({
|
|
1350
1423
|
agentId: a.agentId,
|
|
1351
1424
|
label,
|
|
1352
|
-
cwd: wt,
|
|
1425
|
+
...(LIVE ? { worktreeFor } : { cwd: wt }),
|
|
1353
1426
|
baseRef,
|
|
1354
1427
|
repoRoot, // for copying the repo's local env into the preview worktree
|
|
1355
1428
|
|
|
1356
1429
|
getToken: (id) => tokenByAgent.get(id),
|
|
1357
1430
|
getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
|
|
1431
|
+
getNext: (id) => nextByAgent.get(id) ?? null,
|
|
1358
1432
|
getMcpUrl: () => mcpUrl,
|
|
1359
1433
|
isAlive: () => state.alive,
|
|
1360
1434
|
onChild: (ch) => {
|
|
1361
1435
|
state.child = ch;
|
|
1362
1436
|
},
|
|
1437
|
+
// Which task this lane is holding, so per-process memory can be
|
|
1438
|
+
// attributed to a task rather than to an anonymous pid. "The box is
|
|
1439
|
+
// full" is not actionable; "this task is holding 9GB" is.
|
|
1440
|
+
onIntent: (id) => {
|
|
1441
|
+
state.intentId = id;
|
|
1442
|
+
},
|
|
1363
1443
|
// Hold the preview's stop fn so teardown/removal can kill the detached
|
|
1364
1444
|
// dev-server + tunnel (they survive our exit otherwise).
|
|
1365
1445
|
onPreview: (stop) => {
|
|
@@ -1404,6 +1484,27 @@ export async function runFleetDaemon() {
|
|
|
1404
1484
|
}
|
|
1405
1485
|
});
|
|
1406
1486
|
|
|
1487
|
+
// Tell the app what this machine is doing with itself. Every reconcile,
|
|
1488
|
+
// best-effort, and never awaited — telemetry that can delay a dispatch is
|
|
1489
|
+
// worse than no telemetry.
|
|
1490
|
+
//
|
|
1491
|
+
// The one thing Flowviant could never answer about a task was "why is it
|
|
1492
|
+
// slow", because the box was somebody's laptop and only they could look at
|
|
1493
|
+
// it. Centralising is supposed to make one machine easier to manage than N
|
|
1494
|
+
// laptops; that is only true if the machine is visible. Per-task RSS is the
|
|
1495
|
+
// load-bearing part — "the box is full" is not actionable, "this task is
|
|
1496
|
+
// holding 9GB" is.
|
|
1497
|
+
void reportMergeOutcome(
|
|
1498
|
+
MACHINE_URL,
|
|
1499
|
+
machineSnapshot({
|
|
1500
|
+
worktreeDir: baseDir,
|
|
1501
|
+
tasks: [...workers].map(([, w]) => ({
|
|
1502
|
+
intentId: w.state.intentId ?? null,
|
|
1503
|
+
pid: w.state.child?.pid,
|
|
1504
|
+
})),
|
|
1505
|
+
})
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1407
1508
|
// Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
|
|
1408
1509
|
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
1409
1510
|
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
@@ -1415,7 +1516,7 @@ export async function runFleetDaemon() {
|
|
|
1415
1516
|
// Stop workers whose agent left the roster (removed in the app).
|
|
1416
1517
|
for (const [id, w] of [...workers]) {
|
|
1417
1518
|
if (!rosterIds.has(id)) {
|
|
1418
|
-
warn(`${w.label} removed — stopping it now
|
|
1519
|
+
warn(`${w.label} removed — stopping it now.`);
|
|
1419
1520
|
w.state.alive = false;
|
|
1420
1521
|
// Immediate teardown (Q6=B): kill the in-flight Claude process now; its
|
|
1421
1522
|
// task was already requeued server-side on removal.
|
|
@@ -1429,14 +1530,21 @@ export async function runFleetDaemon() {
|
|
|
1429
1530
|
} catch {
|
|
1430
1531
|
/* best-effort */
|
|
1431
1532
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1533
|
+
// Only poll mode's per-lane tree dies with the lane. A live lane owns no
|
|
1534
|
+
// checkout: the task it was building has its own, which must SURVIVE —
|
|
1535
|
+
// removing a lane requeues its task, and the next lane to pick that task
|
|
1536
|
+
// up resumes in that same directory rather than starting over.
|
|
1537
|
+
if (w.wt) {
|
|
1538
|
+
try {
|
|
1539
|
+
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
1540
|
+
} catch {
|
|
1541
|
+
/* best-effort */
|
|
1542
|
+
}
|
|
1436
1543
|
}
|
|
1437
1544
|
workers.delete(id);
|
|
1438
1545
|
tokenByAgent.delete(id);
|
|
1439
1546
|
hasWorkByAgent.delete(id);
|
|
1547
|
+
nextByAgent.delete(id);
|
|
1440
1548
|
mintedAt.delete(id); // was leaked on removal (finding 14)
|
|
1441
1549
|
}
|
|
1442
1550
|
}
|
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.36.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": {
|