flowviant 0.48.3 → 0.48.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +15 -32
- package/bin/lib/config.mjs +4 -8
- package/bin/lib/fleet.mjs +7 -767
- package/bin/lib/prompts.mjs +22 -326
- package/package.json +2 -2
- package/bin/lib/live.mjs +0 -2151
- package/bin/lib/single.mjs +0 -66
package/bin/lib/fleet.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
isSafePathSegment,
|
|
47
47
|
worktreeDiffstat,
|
|
48
48
|
} from './git.mjs';
|
|
49
|
-
import { c,
|
|
49
|
+
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
50
50
|
import { revertPatch, withPatchLock } from './patch.mjs';
|
|
51
51
|
import {
|
|
52
52
|
sleep,
|
|
@@ -54,21 +54,11 @@ import {
|
|
|
54
54
|
runTurn,
|
|
55
55
|
sawSentinel,
|
|
56
56
|
blockedId,
|
|
57
|
-
SYSTEM_SINGLE,
|
|
58
|
-
SINGLE_KICKOFF,
|
|
59
|
-
SINGLE_RESUME,
|
|
60
57
|
SYSTEM_WIKI,
|
|
61
58
|
WIKI_KICKOFF,
|
|
62
59
|
SYSTEM_REGROUND,
|
|
63
|
-
SYSTEM_PLAN_CHECK,
|
|
64
|
-
PLAN_CHECK_KICKOFF,
|
|
65
60
|
REGROUND_KICKOFF,
|
|
66
|
-
SYSTEM_PLAN,
|
|
67
|
-
PLAN_TURN_KICKOFF,
|
|
68
|
-
SYSTEM_QUICK_EDIT,
|
|
69
|
-
QUICK_EDIT_KICKOFF,
|
|
70
61
|
} from './claude.mjs';
|
|
71
|
-
import { runLiveWorker, readTaskMarker } from './live.mjs';
|
|
72
62
|
import { reapOrphanPreviews } from './preview.mjs';
|
|
73
63
|
import { preflight } from './preflight.mjs';
|
|
74
64
|
import { connectStream } from './stream.mjs';
|
|
@@ -186,55 +176,6 @@ const RUN_DIFFSTAT_URL = FLEET_URL.replace(/\/agents\/?$/, '/run-diffstat');
|
|
|
186
176
|
*/
|
|
187
177
|
const DIFFSTAT_REFRESH_MS = 120_000;
|
|
188
178
|
|
|
189
|
-
function sampleDiffstat(cwd, baseRef, intentId, agentId) {
|
|
190
|
-
let last = '';
|
|
191
|
-
let lastSentAt = 0;
|
|
192
|
-
let alive = true;
|
|
193
|
-
const post = async () => {
|
|
194
|
-
if (!alive) return;
|
|
195
|
-
let stat = null;
|
|
196
|
-
try {
|
|
197
|
-
stat = worktreeDiffstat(cwd, baseRef);
|
|
198
|
-
} catch {
|
|
199
|
-
return; // a worktree mid-reset is not an error worth reporting
|
|
200
|
-
}
|
|
201
|
-
if (!stat) return;
|
|
202
|
-
const key = JSON.stringify(stat);
|
|
203
|
-
if (key === last && Date.now() - lastSentAt < DIFFSTAT_REFRESH_MS) return;
|
|
204
|
-
try {
|
|
205
|
-
const res = await fetch(RUN_DIFFSTAT_URL, {
|
|
206
|
-
method: 'POST',
|
|
207
|
-
headers: {
|
|
208
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
209
|
-
'User-Agent': USER_AGENT,
|
|
210
|
-
'Content-Type': 'application/json',
|
|
211
|
-
},
|
|
212
|
-
signal: AbortSignal.timeout(15_000),
|
|
213
|
-
// The lane, not just the task: the server matches the run on both, so a
|
|
214
|
-
// sample can only ever overwrite the diffstat of THIS lane's own run.
|
|
215
|
-
body: JSON.stringify({ taskId: intentId, agentId, diffstat: stat }),
|
|
216
|
-
});
|
|
217
|
-
// Only a sample the server ACCEPTED counts as sent. Marking it delivered
|
|
218
|
-
// before the round-trip meant a dropped request suppressed every retry
|
|
219
|
-
// for as long as the numbers held still — which is precisely when the
|
|
220
|
-
// reader is about to expire the panel.
|
|
221
|
-
if (res.ok) {
|
|
222
|
-
last = key;
|
|
223
|
-
lastSentAt = Date.now();
|
|
224
|
-
}
|
|
225
|
-
} catch {
|
|
226
|
-
/* best-effort: `last` is untouched, so the next tick tries again */
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
const t = setInterval(() => void post(), 20_000);
|
|
230
|
-
// Kick once after a beat so a fast task still reports something before it ends.
|
|
231
|
-
const first = setTimeout(() => void post(), 5_000);
|
|
232
|
-
return () => {
|
|
233
|
-
alive = false;
|
|
234
|
-
clearInterval(t);
|
|
235
|
-
clearTimeout(first);
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
179
|
|
|
239
180
|
/**
|
|
240
181
|
* Terminal-session presence: tell the server which Claude sessions exist in
|
|
@@ -301,134 +242,6 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
301
242
|
|
|
302
243
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
303
244
|
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
304
|
-
async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
|
|
305
|
-
let resuming = false;
|
|
306
|
-
let needsReset = true; // reset to base before a FRESH task, not on idle polls
|
|
307
|
-
// The task this lane is currently holding. `next` only arrives on a FRESH
|
|
308
|
-
// turn, but a run that comes back from a blocker is still building the same
|
|
309
|
-
// intent — without remembering it here, the entire post-blocker half of a run
|
|
310
|
-
// reports no diffstat and the tray blanks mid-build.
|
|
311
|
-
let heldIntentId = null;
|
|
312
|
-
// The CLI the task in flight is being built by — held across a resume for
|
|
313
|
-
// the reason documented at the assignment below.
|
|
314
|
-
let heldRuntime = 'claude';
|
|
315
|
-
let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
|
|
316
|
-
const enter = (p, fn, msg) => {
|
|
317
|
-
if (phase !== p) {
|
|
318
|
-
phase = p;
|
|
319
|
-
fn(`${label} ${msg}`);
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
while (isAlive()) {
|
|
323
|
-
const token = getToken(agentId);
|
|
324
|
-
if (!token) {
|
|
325
|
-
await sleep(IDLE_SECONDS);
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
// Idle = no claimable work (the server tells us via the roster poll). Don't
|
|
329
|
-
// spawn Claude just to find nothing — that's a wasted API call. A blocked
|
|
330
|
-
// task (resuming) still polls, so its resolution gets picked up.
|
|
331
|
-
if (!resuming && !getHasWork(agentId)) {
|
|
332
|
-
enter('idle', info, 'idle — no work assigned');
|
|
333
|
-
await sleep(IDLE_SECONDS);
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
if (!resuming && needsReset) {
|
|
337
|
-
resetWorktree(cwd, baseRef); // clean slate for a new task
|
|
338
|
-
materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
|
|
339
|
-
needsReset = false;
|
|
340
|
-
}
|
|
341
|
-
// The task the server says is next for this lane, read ONCE per turn: the
|
|
342
|
-
// runtime, model and effort below become process flags, so they must
|
|
343
|
-
// describe the same task the kickoff tells the agent to claim. Re-reading
|
|
344
|
-
// the map mid-turn could pair one task's flags with another's work.
|
|
345
|
-
const next = resuming ? null : getNext?.(agentId) || null;
|
|
346
|
-
if (next?.intentId) heldIntentId = next.intentId;
|
|
347
|
-
// WHICH CLI builds this one. Chosen in the app by @mentioning it and carried
|
|
348
|
-
// on the roster hint; absent (older server, or a task captured before there
|
|
349
|
-
// was a choice) it is Claude, which is what every task ran on until now.
|
|
350
|
-
//
|
|
351
|
-
// A resume must keep the runtime it started on — the session, the worktree
|
|
352
|
-
// and the branch all belong to that CLI, and handing its half-finished work
|
|
353
|
-
// to a different one mid-task is not a fallback, it is a second author.
|
|
354
|
-
if (!resuming) heldRuntime = next?.runtime || 'claude';
|
|
355
|
-
const { dir, args: mcpArgs, env: mcpEnv } = mcpFor(heldRuntime, token, getMcpUrl());
|
|
356
|
-
let out = '';
|
|
357
|
-
// Report what this run is changing, while it is changing it. The commits
|
|
358
|
-
// endpoint can only describe work that has already reached the provider, so
|
|
359
|
-
// without this the app has nothing to say about a task for the whole time it
|
|
360
|
-
// is being built. Only when we know WHICH task this turn is for — the same
|
|
361
|
-
// hint that carries its model and effort — because a diffstat attributed to
|
|
362
|
-
// the wrong run is worse than none. On a resume that is the intent this
|
|
363
|
-
// lane already holds; the worktree it is about to keep editing is the same
|
|
364
|
-
// one, so the samples describe the same run.
|
|
365
|
-
const stopDiffstat = heldIntentId
|
|
366
|
-
? sampleDiffstat(cwd, baseRef, heldIntentId, agentId)
|
|
367
|
-
: null;
|
|
368
|
-
try {
|
|
369
|
-
out = await runTurn({
|
|
370
|
-
prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF(next?.intentId),
|
|
371
|
-
resume: resuming,
|
|
372
|
-
system: SYSTEM_SINGLE,
|
|
373
|
-
cwd,
|
|
374
|
-
runtime: heldRuntime,
|
|
375
|
-
mcpArgs,
|
|
376
|
-
mcpEnv,
|
|
377
|
-
label,
|
|
378
|
-
// Per-task overrides — null/absent means this machine's own defaults
|
|
379
|
-
// (FLOWVIANT_MODEL, and the CLI's own effort). A resume keeps the
|
|
380
|
-
// session it already has, so there is nothing to re-pick there.
|
|
381
|
-
model: next?.model || undefined,
|
|
382
|
-
effort: next?.effort || undefined,
|
|
383
|
-
onSpawn: (ch) => onChild?.(ch),
|
|
384
|
-
});
|
|
385
|
-
} finally {
|
|
386
|
-
stopDiffstat?.();
|
|
387
|
-
// `dir` is null for a runtime that needed no file on disk (Codex reads its
|
|
388
|
-
// token from the environment) — rmSync would throw on undefined.
|
|
389
|
-
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
390
|
-
onChild?.(null);
|
|
391
|
-
}
|
|
392
|
-
if (!isAlive()) break;
|
|
393
|
-
if (blockedId(out)) {
|
|
394
|
-
enter('blocked', warn, `${c.yellow('paused')}${c.dim(' — waiting on your review/answer in Flowviant')}`);
|
|
395
|
-
resuming = true;
|
|
396
|
-
await sleep(POLL_SECONDS);
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
if (sawSentinel(out, 'NOTHING')) {
|
|
400
|
-
enter('idle', info, 'idle — no work assigned');
|
|
401
|
-
resuming = false;
|
|
402
|
-
heldIntentId = null; // let go of the task, and of its diffstat
|
|
403
|
-
await sleep(IDLE_SECONDS);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (sawSentinel(out, 'DONE')) {
|
|
407
|
-
ok(`${label} ${c.dim('finished a task — PR opened for your review')}`);
|
|
408
|
-
phase = '';
|
|
409
|
-
resuming = false;
|
|
410
|
-
needsReset = true;
|
|
411
|
-
heldIntentId = null;
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
// No sentinel — the turn didn't complete the protocol. Almost always the
|
|
415
|
-
// flowviant MCP failed to surface its tools (usually a stale worker token).
|
|
416
|
-
// Drop the cached token so the next poll re-mints a fresh one, then retry —
|
|
417
|
-
// don't fake a blocker or a completion.
|
|
418
|
-
enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
|
|
419
|
-
onTokenSuspect?.(agentId);
|
|
420
|
-
// A no-sentinel turn while RESUMING a blocked task is a transient MCP/token
|
|
421
|
-
// failure, not completion — retry in place and KEEP the worktree. Resetting
|
|
422
|
-
// here would wipe the blocked task's uncommitted changes. Only a fresh-task
|
|
423
|
-
// turn (not resuming) warrants a clean slate next time.
|
|
424
|
-
if (!resuming) {
|
|
425
|
-
needsReset = true;
|
|
426
|
-
heldIntentId = null; // fresh slate next turn — nothing held to sample
|
|
427
|
-
}
|
|
428
|
-
await sleep(IDLE_SECONDS);
|
|
429
|
-
}
|
|
430
|
-
info(`${label} stopped`);
|
|
431
|
-
}
|
|
432
245
|
|
|
433
246
|
export async function runFleetDaemon() {
|
|
434
247
|
console.log('');
|
|
@@ -466,19 +279,6 @@ export async function runFleetDaemon() {
|
|
|
466
279
|
// can hand any lane any task, and two tasks can never be in each other's
|
|
467
280
|
// files even when one is mid-edit.
|
|
468
281
|
const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
|
|
469
|
-
const worktreeFor = (intentId) => {
|
|
470
|
-
const r = ensureWorktree(repoRoot, taskWorktreePath(intentId), baseRef);
|
|
471
|
-
// Only on creation: a resumed tree already has its env, and rewriting it
|
|
472
|
-
// mid-task would clobber anything the agent changed.
|
|
473
|
-
if (r.fresh) {
|
|
474
|
-
try {
|
|
475
|
-
materializeInto(r.path);
|
|
476
|
-
} catch {
|
|
477
|
-
/* best-effort — the task still builds, secrets-backed paths may 500 */
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
return r;
|
|
481
|
-
};
|
|
482
282
|
try {
|
|
483
283
|
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
484
284
|
if (kb > 1024)
|
|
@@ -688,60 +488,6 @@ export async function runFleetDaemon() {
|
|
|
688
488
|
}
|
|
689
489
|
};
|
|
690
490
|
|
|
691
|
-
// Plan checks: the ground-truth pass. Generation drafted these against a
|
|
692
|
-
// module manifest and wiki summaries — proxies for the repo. This runs where
|
|
693
|
-
// the checkout is, opens the real files, and reports corrections back into the
|
|
694
|
-
// thread. Read-only by construction; it never edits.
|
|
695
|
-
/**
|
|
696
|
-
* Pull the plan-check JSON off the tail of a Claude turn.
|
|
697
|
-
*
|
|
698
|
-
* The model is told to end with a bare JSON object, but a turn can trail
|
|
699
|
-
* prose, a fence, or a stray newline. Scan backwards for the last balanced
|
|
700
|
-
* object and validate it hard: anything shaped wrong is dropped rather than
|
|
701
|
-
* written into someone's plan. Returns null when nothing usable was found.
|
|
702
|
-
*/
|
|
703
|
-
const parsePlanChecks = (out, intents) => {
|
|
704
|
-
const text = String(out ?? '');
|
|
705
|
-
const known = new Set(intents.map((i) => i.id));
|
|
706
|
-
const end = text.lastIndexOf('}');
|
|
707
|
-
if (end === -1) return null;
|
|
708
|
-
// NOTE: `lastIndexOf(x, -1)` returns 0, NOT -1 — the position argument is
|
|
709
|
-
// clamped, so the obvious `start = lastIndexOf('{', start - 1)` loop spins
|
|
710
|
-
// forever once it reaches index 0 and the parse fails. That hangs the
|
|
711
|
-
// daemon's event loop, not just this job. Walk with an explicit stop, and
|
|
712
|
-
// cap the attempts so a pathological turn can't burn the poll cycle either.
|
|
713
|
-
let start = text.lastIndexOf('{', end);
|
|
714
|
-
for (let attempts = 0; start !== -1 && attempts < 200; attempts++) {
|
|
715
|
-
let parsed = null;
|
|
716
|
-
try {
|
|
717
|
-
parsed = JSON.parse(text.slice(start, end + 1));
|
|
718
|
-
} catch {
|
|
719
|
-
/* not a complete object at this offset — step back and retry */
|
|
720
|
-
}
|
|
721
|
-
if (!parsed || !Array.isArray(parsed.checks)) {
|
|
722
|
-
if (start === 0) break;
|
|
723
|
-
start = text.lastIndexOf('{', start - 1);
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
return parsed.checks
|
|
727
|
-
.filter((ch) => ch && typeof ch.id === 'string' && known.has(ch.id))
|
|
728
|
-
.map((ch) => ({
|
|
729
|
-
id: ch.id,
|
|
730
|
-
alreadyBuilt: ch.alreadyBuilt === true,
|
|
731
|
-
evidence: typeof ch.evidence === 'string' ? ch.evidence.slice(0, 300) : '',
|
|
732
|
-
anchors: Array.isArray(ch.anchors)
|
|
733
|
-
? ch.anchors.filter((a) => typeof a === 'string' && a.length < 200).slice(0, 6)
|
|
734
|
-
: [],
|
|
735
|
-
points:
|
|
736
|
-
typeof ch.points === 'number' && Number.isFinite(ch.points)
|
|
737
|
-
? Math.max(0, Math.min(13, Math.round(ch.points)))
|
|
738
|
-
: null,
|
|
739
|
-
note: typeof ch.note === 'string' ? ch.note.slice(0, 400) : '',
|
|
740
|
-
}))
|
|
741
|
-
.slice(0, 30);
|
|
742
|
-
}
|
|
743
|
-
return null;
|
|
744
|
-
};
|
|
745
491
|
|
|
746
492
|
/**
|
|
747
493
|
* Everything that reads or rewrites the shared `wikiWt` worktree takes this:
|
|
@@ -789,419 +535,11 @@ export async function runFleetDaemon() {
|
|
|
789
535
|
}
|
|
790
536
|
};
|
|
791
537
|
|
|
792
|
-
const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
|
|
793
538
|
// Machine telemetry — what the box is doing with itself, for the admin view.
|
|
794
539
|
const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
|
|
795
|
-
const checkingPlans = new Set();
|
|
796
|
-
const processPlanCheckJobs = (jobs) => {
|
|
797
|
-
for (const job of jobs ?? []) {
|
|
798
|
-
// New name first; the roster mirrors `intents` off `tasks` for exactly
|
|
799
|
-
// this fallback window.
|
|
800
|
-
const planTasks = Array.isArray(job?.tasks) ? job.tasks : job?.intents;
|
|
801
|
-
if (!job || typeof job.id !== 'string' || !Array.isArray(planTasks)) continue;
|
|
802
|
-
if (checkingPlans.has(job.id)) continue;
|
|
803
|
-
if (planTasks.length === 0) continue;
|
|
804
|
-
checkingPlans.add(job.id);
|
|
805
|
-
(async () => {
|
|
806
|
-
try {
|
|
807
|
-
// WHICH CLI answers a turn nobody @mentioned. Resolved per job rather
|
|
808
|
-
// than once at startup: a CLI can be installed while the daemon runs.
|
|
809
|
-
const planRt = pickRuntimeFor('consult');
|
|
810
|
-
if (!planRt) {
|
|
811
|
-
warn(`plan check for "${job.title}" skipped — no installed CLI can run a read-only turn`);
|
|
812
|
-
checkingPlans.delete(job.id);
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
note(`${c.cyan('plan')} ${c.dim(`— checking "${job.title}" against your code…`)}`);
|
|
816
|
-
const out = await withWikiLock(async () => {
|
|
817
|
-
ensureWikiWorktree();
|
|
818
|
-
return runTurn({
|
|
819
|
-
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: planTasks }),
|
|
820
|
-
resume: false,
|
|
821
|
-
system: SYSTEM_PLAN_CHECK,
|
|
822
|
-
cwd: wikiWt,
|
|
823
|
-
// Reads the repo and reports JSON — it authors nothing either.
|
|
824
|
-
readOnly: true,
|
|
825
|
-
runtime: planRt,
|
|
826
|
-
label: c.cyan('[plan]'),
|
|
827
|
-
});
|
|
828
|
-
});
|
|
829
|
-
const checks = parsePlanChecks(out, planTasks);
|
|
830
|
-
if (checks === null) {
|
|
831
|
-
warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
|
|
832
|
-
}
|
|
833
|
-
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
834
|
-
taskId: job.id,
|
|
835
|
-
checks: checks ?? [],
|
|
836
|
-
});
|
|
837
|
-
if (checks?.length) {
|
|
838
|
-
ok(`${c.cyan('plan')} ${c.dim(`— ${checks.length} correction${checks.length === 1 ? '' : 's'} for "${job.title}"`)}`);
|
|
839
|
-
} else {
|
|
840
|
-
ok(`${c.cyan('plan')} ${c.dim(`— "${job.title}" checks out against your code`)}`);
|
|
841
|
-
}
|
|
842
|
-
} catch (e) {
|
|
843
|
-
warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
|
|
844
|
-
// Clear the flag anyway — a stuck job would re-run every poll forever.
|
|
845
|
-
await reportMergeOutcome(PLAN_CHECK_DONE_URL, { taskId: job.id, checks: [] });
|
|
846
|
-
} finally {
|
|
847
|
-
checkingPlans.delete(job.id);
|
|
848
|
-
}
|
|
849
|
-
})();
|
|
850
|
-
}
|
|
851
|
-
};
|
|
852
540
|
|
|
853
|
-
// ── Planning sessions ────────────────────────────────────────────────────
|
|
854
|
-
//
|
|
855
|
-
// A turn in a plan thread, answered inside a HELD session. This was the
|
|
856
|
-
// consult, which answered one question in prose and kept nothing: it existed
|
|
857
|
-
// because the planner was a different, weaker brain and this turn's only job
|
|
858
|
-
// was to correct it from the real code. That planner is gone, so the session
|
|
859
|
-
// reads the repo AND writes the plan, over many turns, in one context.
|
|
860
|
-
//
|
|
861
|
-
// Two things changed shape as a result.
|
|
862
|
-
//
|
|
863
|
-
// ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
|
|
864
|
-
// "continue the last session in this directory" (`--continue`, `resume
|
|
865
|
-
// --last`) rather than by session id, so the WORKING DIRECTORY *is* the
|
|
866
|
-
// session handle. A shared directory would have made two plans on one machine
|
|
867
|
-
// take turns wearing each other's context — and the wiki queue hard-resets
|
|
868
|
-
// that directory between tasks, which would pull the files out from under a
|
|
869
|
-
// session mid-argument. A private detached checkout per plan also means plan
|
|
870
|
-
// turns no longer queue behind the wiki lock.
|
|
871
|
-
//
|
|
872
|
-
// IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
|
|
873
|
-
// slices, re-shapes them, drops them and maintains the spec, all of which are
|
|
874
|
-
// control-plane calls. The token is the fleet's PLAN principal, whose entire
|
|
875
|
-
// tool set is those five: it cannot claim, cannot open a worktree, cannot
|
|
876
|
-
// commit. That absence is the product rule, not a hardening measure — it is
|
|
877
|
-
// what makes "add a dark mode toggle" typed at a plan add a slice instead of
|
|
878
|
-
// building one, with nothing reading the sentence to decide.
|
|
879
|
-
const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
|
|
880
|
-
const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
|
|
881
|
-
const answering = new Set();
|
|
882
|
-
const consultAttempts = new Map(); // turn id -> tries
|
|
883
|
-
/** Give up after this many turns on one message. A /consult-done that never
|
|
884
|
-
* reaches the server (offline, 500) would otherwise re-run the whole Claude
|
|
885
|
-
* turn every poll, forever, on the owner's quota. */
|
|
886
|
-
const MAX_CONSULT_TRIES = 3;
|
|
887
|
-
/** ONE planning turn at a time on this machine. Sessions are per-plan so they
|
|
888
|
-
* no longer collide on a directory, but the roster can hand back a batch, and
|
|
889
|
-
* un-awaited spawns would put N concurrent CLI processes on someone's laptop
|
|
890
|
-
* for what is, on the human's side, a chat. */
|
|
891
|
-
let consultChain = Promise.resolve();
|
|
892
541
|
|
|
893
|
-
/**
|
|
894
|
-
* The plan credential, cached until it stops working.
|
|
895
|
-
*
|
|
896
|
-
* Minted lazily rather than at startup: most daemons never host a planning
|
|
897
|
-
* session, and a token nobody uses is a credential sitting on disk for no
|
|
898
|
-
* reason. Rotated by the server on every mint, so a re-mint after a 401 is the
|
|
899
|
-
* recovery path.
|
|
900
|
-
*/
|
|
901
|
-
let planToken = null;
|
|
902
|
-
const mintPlanToken = async (force = false) => {
|
|
903
|
-
if (planToken && !force) return planToken;
|
|
904
|
-
try {
|
|
905
|
-
const res = await fetch(PLAN_TOKEN_URL, {
|
|
906
|
-
method: 'POST',
|
|
907
|
-
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
908
|
-
});
|
|
909
|
-
if (!res.ok) return null;
|
|
910
|
-
const data = await res.json().catch(() => null);
|
|
911
|
-
planToken = data?.data?.token ?? null;
|
|
912
|
-
return planToken;
|
|
913
|
-
} catch {
|
|
914
|
-
return null;
|
|
915
|
-
}
|
|
916
|
-
};
|
|
917
542
|
|
|
918
|
-
/**
|
|
919
|
-
* This plan's session directory — its context, expressed as a place.
|
|
920
|
-
*
|
|
921
|
-
* A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
|
|
922
|
-
* private so `--continue` resumes this argument rather than whichever ran last
|
|
923
|
-
* on the box, persistent so it survives the daemon restarting or updating
|
|
924
|
-
* under it. Re-pointed at the current base each turn, because "reads your
|
|
925
|
-
* code" has to mean the code as it is now — a plan that runs for days would
|
|
926
|
-
* otherwise keep answering from the commit it was opened at.
|
|
927
|
-
*
|
|
928
|
-
* Returns null when the id is not a safe path segment: it comes off the wire.
|
|
929
|
-
*/
|
|
930
|
-
const planWtFor = (planId) => {
|
|
931
|
-
if (!isSafePathSegment(planId)) return null;
|
|
932
|
-
const wt = join(baseDir, 'plans', planId);
|
|
933
|
-
const fresh = !existsSync(wt);
|
|
934
|
-
if (fresh) {
|
|
935
|
-
try {
|
|
936
|
-
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
937
|
-
} catch {
|
|
938
|
-
git(['worktree', 'prune'], repoRoot);
|
|
939
|
-
try {
|
|
940
|
-
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
941
|
-
} catch {
|
|
942
|
-
return null;
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
} else {
|
|
946
|
-
try {
|
|
947
|
-
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
948
|
-
git(['checkout', '--detach', baseRef], wt);
|
|
949
|
-
git(['reset', '--hard', baseRef], wt);
|
|
950
|
-
git(['clean', '-fd'], wt);
|
|
951
|
-
} catch {
|
|
952
|
-
/* offline, or a turn left it dirty — read what we have */
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
return { wt, fresh };
|
|
956
|
-
};
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Retire the least-recently-touched session directories.
|
|
960
|
-
*
|
|
961
|
-
* The bound belongs HERE, in the machine, and never in the interface: ten
|
|
962
|
-
* plans open across a team is ten checkouts on one box, which is a resource
|
|
963
|
-
* question. Announcing a session limit in the app would be advertising
|
|
964
|
-
* capacity, which this product does not do. A retired session simply rebuilds
|
|
965
|
-
* from the spec next time it is asked for — the fallback the server already
|
|
966
|
-
* expects, and which the thread says out loud when it happens.
|
|
967
|
-
*/
|
|
968
|
-
const MAX_PLAN_SESSIONS = 8;
|
|
969
|
-
const planTouched = new Map(); // planId -> ms
|
|
970
|
-
const retireIdlePlanSessions = () => {
|
|
971
|
-
const dir = join(baseDir, 'plans');
|
|
972
|
-
if (!existsSync(dir)) return;
|
|
973
|
-
let ids;
|
|
974
|
-
try {
|
|
975
|
-
ids = readdirSync(dir);
|
|
976
|
-
} catch {
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (ids.length <= MAX_PLAN_SESSIONS) return;
|
|
980
|
-
const oldestFirst = ids.sort(
|
|
981
|
-
(a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
|
|
982
|
-
);
|
|
983
|
-
for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
|
|
984
|
-
try {
|
|
985
|
-
git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
|
|
986
|
-
} catch {
|
|
987
|
-
try {
|
|
988
|
-
rmSync(join(dir, id), { recursive: true, force: true });
|
|
989
|
-
} catch {
|
|
990
|
-
/* it is a directory we will overwrite next time; not worth failing a turn */
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
planTouched.delete(id);
|
|
994
|
-
}
|
|
995
|
-
try {
|
|
996
|
-
git(['worktree', 'prune'], repoRoot);
|
|
997
|
-
} catch {
|
|
998
|
-
/* best effort */
|
|
999
|
-
}
|
|
1000
|
-
};
|
|
1001
|
-
|
|
1002
|
-
// Quick edits — a SECOND Claude alongside a task this machine is already
|
|
1003
|
-
// building. Unlike every other roster job it does not get a worktree of its
|
|
1004
|
-
// own: the whole point is to work in the one the running task opened, on that
|
|
1005
|
-
// branch, so the change rides along with the delivery instead of becoming a
|
|
1006
|
-
// second thing to merge.
|
|
1007
|
-
const JOIN_TAKE_URL = FLEET_URL.replace(/\/agents\/?$/, '/join-take');
|
|
1008
|
-
const JOIN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/join-done');
|
|
1009
|
-
const joining = new Set();
|
|
1010
|
-
/** ONE quick edit at a time, ACROSS worktrees. Two of them in the same tree
|
|
1011
|
-
* would fight over the index; two in different trees would still be two extra
|
|
1012
|
-
* Claudes on the owner's account on top of the tasks already running. */
|
|
1013
|
-
let joinChain = Promise.resolve();
|
|
1014
|
-
|
|
1015
|
-
/** The worktree currently building this intent, or null if this machine isn't.
|
|
1016
|
-
* Now a direct lookup rather than a scan: a task's checkout is named after
|
|
1017
|
-
* the task, so there is exactly one place it could be. The marker is still
|
|
1018
|
-
* consulted, but for LIFECYCLE rather than identity — a directory that
|
|
1019
|
-
* outlived its run (finished, cleared its marker, kept for the review
|
|
1020
|
-
* preview) exists but is not building anything, and must not take an edit. */
|
|
1021
|
-
const worktreeBuilding = (intentId) => {
|
|
1022
|
-
const wt = taskWorktreePath(intentId);
|
|
1023
|
-
try {
|
|
1024
|
-
if (existsSync(wt) && readTaskMarker(wt) === intentId) return { wt };
|
|
1025
|
-
} catch {
|
|
1026
|
-
/* a worktree that vanished isn't building anything */
|
|
1027
|
-
}
|
|
1028
|
-
return null;
|
|
1029
|
-
};
|
|
1030
|
-
|
|
1031
|
-
const processJoinJobs = (jobs) => {
|
|
1032
|
-
for (const job of jobs ?? []) {
|
|
1033
|
-
if (!job || typeof job.id !== 'string' || !job.instruction) continue;
|
|
1034
|
-
if (joining.has(job.id)) continue;
|
|
1035
|
-
joining.add(job.id);
|
|
1036
|
-
joinChain = joinChain.then(async () => {
|
|
1037
|
-
let settled = false;
|
|
1038
|
-
try {
|
|
1039
|
-
const target = worktreeBuilding(job.taskId ?? job.intentId);
|
|
1040
|
-
if (!target) {
|
|
1041
|
-
// The run ended (or moved) between the human pressing ⚡ and this
|
|
1042
|
-
// poll. Settle rather than retry: there is no worktree to join, and
|
|
1043
|
-
// an unsettled row holds the reset interlock open forever.
|
|
1044
|
-
await reportMergeOutcome(JOIN_DONE_URL, {
|
|
1045
|
-
joinId: job.id,
|
|
1046
|
-
ok: false,
|
|
1047
|
-
result: 'that task is no longer building on this machine',
|
|
1048
|
-
});
|
|
1049
|
-
settled = true;
|
|
1050
|
-
return;
|
|
1051
|
-
}
|
|
1052
|
-
// Compare-and-set BEFORE spending a Claude turn: two lanes can wake on
|
|
1053
|
-
// the same push, and running one instruction twice into one worktree
|
|
1054
|
-
// is exactly the double-edit this is meant to avoid.
|
|
1055
|
-
const quickRt = pickRuntimeFor('build');
|
|
1056
|
-
if (!quickRt) return; // nothing here can edit code; leave the join unclaimed
|
|
1057
|
-
const claim = await postForData(JOIN_TAKE_URL, { joinId: job.id });
|
|
1058
|
-
if (!claim?.taken) return;
|
|
1059
|
-
note(
|
|
1060
|
-
`${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.taskTitle || job.intentTitle || 'a task'}"`)}`
|
|
1061
|
-
);
|
|
1062
|
-
const out = await runTurn({
|
|
1063
|
-
prompt: QUICK_EDIT_KICKOFF({
|
|
1064
|
-
intentTitle: job.taskTitle ?? job.intentTitle,
|
|
1065
|
-
instruction: job.instruction,
|
|
1066
|
-
askedByName: job.askedByName,
|
|
1067
|
-
}),
|
|
1068
|
-
// Never resume: this is its own tiny turn, not a continuation of the
|
|
1069
|
-
// task's session. Resuming would hand it the other agent's context
|
|
1070
|
-
// and, with it, the other agent's job.
|
|
1071
|
-
resume: false,
|
|
1072
|
-
system: SYSTEM_QUICK_EDIT,
|
|
1073
|
-
cwd: target.wt,
|
|
1074
|
-
runtime: quickRt,
|
|
1075
|
-
// No MCP: a join records no run, claims nothing, completes nothing.
|
|
1076
|
-
// Its only report is the one this daemon posts below.
|
|
1077
|
-
label: c.cyan('[quick]'),
|
|
1078
|
-
});
|
|
1079
|
-
const summary = (out || '').trim();
|
|
1080
|
-
await reportMergeOutcome(JOIN_DONE_URL, {
|
|
1081
|
-
joinId: job.id,
|
|
1082
|
-
ok: summary.length > 0,
|
|
1083
|
-
// Scrub: a summary can quote config or env-adjacent code.
|
|
1084
|
-
result: envScrub(summary).slice(0, 4000) || 'no change reported',
|
|
1085
|
-
});
|
|
1086
|
-
settled = true;
|
|
1087
|
-
ok(`${c.cyan('quick')} ${c.dim('— landed on the task branch')}`);
|
|
1088
|
-
} catch (e) {
|
|
1089
|
-
warn(`quick edit failed: ${e?.message ?? e}`);
|
|
1090
|
-
if (!settled) {
|
|
1091
|
-
await reportMergeOutcome(JOIN_DONE_URL, {
|
|
1092
|
-
joinId: job.id,
|
|
1093
|
-
ok: false,
|
|
1094
|
-
result: e?.message ?? 'the change could not be applied',
|
|
1095
|
-
}).catch(() => {});
|
|
1096
|
-
}
|
|
1097
|
-
} finally {
|
|
1098
|
-
joining.delete(job.id);
|
|
1099
|
-
}
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
};
|
|
1103
|
-
|
|
1104
|
-
const processConsultJobs = (jobs) => {
|
|
1105
|
-
for (const job of jobs ?? []) {
|
|
1106
|
-
if (!job || typeof job.id !== 'string' || !job.question) continue;
|
|
1107
|
-
if (answering.has(job.id)) continue;
|
|
1108
|
-
const tries = (consultAttempts.get(job.id) ?? 0) + 1;
|
|
1109
|
-
if (tries > MAX_CONSULT_TRIES) continue;
|
|
1110
|
-
consultAttempts.set(job.id, tries);
|
|
1111
|
-
answering.add(job.id);
|
|
1112
|
-
consultChain = consultChain.then(async () => {
|
|
1113
|
-
try {
|
|
1114
|
-
note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
|
|
1115
|
-
// The profile is the enforcement, not the prompt: this turn is steered
|
|
1116
|
-
// by anything a project editor can type, and it holds write tools. A
|
|
1117
|
-
// runtime that cannot express `plan` does not get the job rather than
|
|
1118
|
-
// getting it with guarantees nobody wrote down — which today excludes
|
|
1119
|
-
// Antigravity, whose mediated shape fits a build and not an argument.
|
|
1120
|
-
const planRt = pickRuntimeFor('plan');
|
|
1121
|
-
if (!planRt) {
|
|
1122
|
-
warn('a planning turn is waiting, but no installed CLI can run a planning session');
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
const token = await mintPlanToken();
|
|
1126
|
-
if (!token) {
|
|
1127
|
-
warn('a planning turn is waiting, but the plan credential could not be minted');
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
const dir = planWtFor(job.taskId);
|
|
1131
|
-
if (!dir) {
|
|
1132
|
-
warn(`a planning turn is waiting, but its session directory could not be opened`);
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
planTouched.set(job.taskId, Date.now());
|
|
1136
|
-
// Resume only when this plan already HAS a session here. A fresh
|
|
1137
|
-
// directory means either the first turn or a session we retired, and
|
|
1138
|
-
// both want the same thing: start over from the spec, which the
|
|
1139
|
-
// kickoff carries. `--continue` against an empty directory is not an
|
|
1140
|
-
// error on every CLI, so asking `fresh` is what keeps it honest.
|
|
1141
|
-
const resume = !dir.fresh && Boolean(job.sessionRef);
|
|
1142
|
-
const mcp = mcpFor(planRt, token, mcpUrl);
|
|
1143
|
-
let out;
|
|
1144
|
-
try {
|
|
1145
|
-
out = await runTurn({
|
|
1146
|
-
prompt: PLAN_TURN_KICKOFF({
|
|
1147
|
-
planId: job.taskId,
|
|
1148
|
-
planTitle: job.planTitle,
|
|
1149
|
-
question: job.question,
|
|
1150
|
-
askedByName: job.askedByName,
|
|
1151
|
-
// Sent only when we are NOT resuming: a live session already has
|
|
1152
|
-
// the argument in its context, and re-stating the spec every
|
|
1153
|
-
// turn would spend tokens telling it what it just wrote. On a
|
|
1154
|
-
// rebuild it is the whole inheritance.
|
|
1155
|
-
spec: resume ? null : job.spec,
|
|
1156
|
-
}),
|
|
1157
|
-
resume,
|
|
1158
|
-
system: SYSTEM_PLAN,
|
|
1159
|
-
cwd: dir.wt,
|
|
1160
|
-
// Read the repo, write the PLAN. No Edit/Write/commit anywhere in
|
|
1161
|
-
// the toolset — the prompt says so too, but the prompt is what an
|
|
1162
|
-
// injected message competes with.
|
|
1163
|
-
planPerm: true,
|
|
1164
|
-
mcpArgs: mcp.args,
|
|
1165
|
-
mcpEnv: mcp.env,
|
|
1166
|
-
runtime: planRt,
|
|
1167
|
-
label: c.cyan('[plan]'),
|
|
1168
|
-
});
|
|
1169
|
-
} finally {
|
|
1170
|
-
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
1171
|
-
}
|
|
1172
|
-
const answer = (out || '').trim();
|
|
1173
|
-
const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
1174
|
-
consultId: job.id,
|
|
1175
|
-
ok: answer.length > 0,
|
|
1176
|
-
// Scrub: a reply can quote config or env-adjacent code.
|
|
1177
|
-
answer: envScrub(answer).slice(0, 8000),
|
|
1178
|
-
// The handle the server stores, reported on EVERY turn: a session we
|
|
1179
|
-
// had to rebuild comes back under a new directory state, and a
|
|
1180
|
-
// stored handle that does not follow it leaves later turns trying to
|
|
1181
|
-
// resume something that is gone.
|
|
1182
|
-
sessionRef: dir.wt,
|
|
1183
|
-
});
|
|
1184
|
-
if (posted) consultAttempts.delete(job.id);
|
|
1185
|
-
ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
|
|
1186
|
-
retireIdlePlanSessions();
|
|
1187
|
-
} catch (e) {
|
|
1188
|
-
// Settle it. A turn that cannot be answered must not re-burn quota
|
|
1189
|
-
// every poll, and silence would leave the human waiting on a machine
|
|
1190
|
-
// that already gave up.
|
|
1191
|
-
await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
1192
|
-
consultId: job.id,
|
|
1193
|
-
ok: false,
|
|
1194
|
-
// Scrub, like the success path: an exception routinely quotes
|
|
1195
|
-
// command output, and command output can quote a synced secret.
|
|
1196
|
-
answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
|
|
1197
|
-
});
|
|
1198
|
-
warn(`planning turn failed: ${e?.message ?? e}`);
|
|
1199
|
-
} finally {
|
|
1200
|
-
answering.delete(job.id);
|
|
1201
|
-
}
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
|
-
};
|
|
1205
543
|
|
|
1206
544
|
// ── Work sessions — the Workbench tabs ─────────────────────────────────────
|
|
1207
545
|
//
|
|
@@ -1723,7 +1061,6 @@ export async function runFleetDaemon() {
|
|
|
1723
1061
|
let rosterSig = null; // last roster membership, to log changes only
|
|
1724
1062
|
let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
|
|
1725
1063
|
let cappedWarned = false; // say once, not every reconcile, why extra lanes idle
|
|
1726
|
-
let joinCount = 0; // for stable per-agent label colours
|
|
1727
1064
|
|
|
1728
1065
|
// ── Push channel: a server wake short-circuits the reconcile sleep so a job is
|
|
1729
1066
|
// picked up in ~a round trip instead of on the next poll. The socket only
|
|
@@ -1828,8 +1165,6 @@ export async function runFleetDaemon() {
|
|
|
1828
1165
|
void flushWorkReports();
|
|
1829
1166
|
processMergeJobs(roster.mergeJobs);
|
|
1830
1167
|
processPatchRevertJobs(roster.patchRevertJobs);
|
|
1831
|
-
processPlanCheckJobs(roster.planCheckJobs);
|
|
1832
|
-
processConsultJobs(roster.consultJobs);
|
|
1833
1168
|
processWorkTurns(roster.workTurnJobs);
|
|
1834
1169
|
// The roster's live-session list rides along: an ENDED session's ship
|
|
1835
1170
|
// must not be refused by checks whose remedies need a live tab.
|
|
@@ -1847,7 +1182,6 @@ export async function runFleetDaemon() {
|
|
|
1847
1182
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1848
1183
|
// is already a tab, not something to offer adopting).
|
|
1849
1184
|
void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
|
|
1850
|
-
processJoinJobs(roster.joinJobs);
|
|
1851
1185
|
processCleanupJobs(roster.cleanupJobs);
|
|
1852
1186
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
1853
1187
|
|
|
@@ -1856,8 +1190,12 @@ export async function runFleetDaemon() {
|
|
|
1856
1190
|
if (sig !== rosterSig) {
|
|
1857
1191
|
rosterSig = sig;
|
|
1858
1192
|
if (rosterIds.size === 0) {
|
|
1859
|
-
|
|
1860
|
-
|
|
1193
|
+
// `agents` is permanently [] — the lanes it counted died with dispatch
|
|
1194
|
+
// and the array survives only as wire compat. So this branch is the one
|
|
1195
|
+
// that always runs, and it used to point at the Cockpit, a surface
|
|
1196
|
+
// deleted 2026-08-04 that now redirects to the Board. Say what is
|
|
1197
|
+
// actually true instead: the machine is up, and work starts in a tab.
|
|
1198
|
+
info('Machine online. Open a tab in Flowviant → Workbench to start working.');
|
|
1861
1199
|
} else {
|
|
1862
1200
|
note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
|
|
1863
1201
|
}
|
|
@@ -1868,104 +1206,6 @@ export async function runFleetDaemon() {
|
|
|
1868
1206
|
info('idle — waiting for agents…');
|
|
1869
1207
|
}
|
|
1870
1208
|
|
|
1871
|
-
for (const a of roster.agents) {
|
|
1872
|
-
if (a.token) {
|
|
1873
|
-
tokenByAgent.set(a.agentId, a.token);
|
|
1874
|
-
mintedAt.set(a.agentId, Date.now());
|
|
1875
|
-
}
|
|
1876
|
-
hasWorkByAgent.set(a.agentId, !!a.hasWork);
|
|
1877
|
-
// The hint's task id, new name first. Normalized ONTO `intentId` here so
|
|
1878
|
-
// every downstream read (poll worker, kickoff, diffstat attribution)
|
|
1879
|
-
// keeps its one spelling — intent is still the daemon's internal word,
|
|
1880
|
-
// taskId is the wire's.
|
|
1881
|
-
const nextId = a.next && (a.next.taskId ?? a.next.intentId);
|
|
1882
|
-
if (a.next && typeof nextId === 'string')
|
|
1883
|
-
nextByAgent.set(a.agentId, { ...a.next, intentId: nextId });
|
|
1884
|
-
else nextByAgent.delete(a.agentId);
|
|
1885
|
-
if (!workers.has(a.agentId)) {
|
|
1886
|
-
// Local ceiling, enforced and not merely requested. The roster can carry
|
|
1887
|
-
// more lanes than this machine asked for — someone added capacity by
|
|
1888
|
-
// hand, or a second machine shares the fleet — and each extra worker is
|
|
1889
|
-
// another Claude session, another worktree and another dev server on
|
|
1890
|
-
// somebody's laptop. Skipping the spawn does NOT strand the work: an
|
|
1891
|
-
// @mention addresses the FLEET, so any running lane can claim it; the
|
|
1892
|
-
// tasks queue behind the ones we did start.
|
|
1893
|
-
if (workers.size >= MAX_CONCURRENT) {
|
|
1894
|
-
if (!cappedWarned) {
|
|
1895
|
-
cappedWarned = true;
|
|
1896
|
-
info(
|
|
1897
|
-
`running ${MAX_CONCURRENT} task${MAX_CONCURRENT === 1 ? '' : 's'} at a time on this machine — ` +
|
|
1898
|
-
`more will queue (FLOWVIANT_MAX_CONCURRENT to change)`
|
|
1899
|
-
);
|
|
1900
|
-
}
|
|
1901
|
-
continue;
|
|
1902
|
-
}
|
|
1903
|
-
// LIVE lanes get NO checkout of their own — they ask for one per task,
|
|
1904
|
-
// once they know which task. Poll mode is the legacy escape hatch and
|
|
1905
|
-
// keeps its per-lane tree; it predates per-task sandboxes and isn't
|
|
1906
|
-
// worth restructuring for a path nobody runs by default.
|
|
1907
|
-
let wt = null;
|
|
1908
|
-
if (!LIVE) {
|
|
1909
|
-
try {
|
|
1910
|
-
ensureWorktree(repoRoot, (wt = join(baseDir, `agent-${a.agentId}`)), baseRef);
|
|
1911
|
-
} catch (e) {
|
|
1912
|
-
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
1913
|
-
continue;
|
|
1914
|
-
}
|
|
1915
|
-
try {
|
|
1916
|
-
materializeInto(wt); // synced env into the fresh worktree
|
|
1917
|
-
} catch {
|
|
1918
|
-
/* best-effort */
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
|
|
1922
|
-
const label = colorFn(`[${a.name}]`);
|
|
1923
|
-
const state = { alive: true, child: null };
|
|
1924
|
-
ok(`${label} ${c.dim(LIVE ? 'online — live session' : 'online — worktree ready')}`);
|
|
1925
|
-
const workerFn = LIVE ? runLiveWorker : runFleetWorker;
|
|
1926
|
-
const promise = workerFn({
|
|
1927
|
-
agentId: a.agentId,
|
|
1928
|
-
label,
|
|
1929
|
-
...(LIVE ? { worktreeFor } : { cwd: wt }),
|
|
1930
|
-
baseRef,
|
|
1931
|
-
repoRoot, // for copying the repo's local env into the preview worktree
|
|
1932
|
-
|
|
1933
|
-
getToken: (id) => tokenByAgent.get(id),
|
|
1934
|
-
getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
|
|
1935
|
-
getNext: (id) => nextByAgent.get(id) ?? null,
|
|
1936
|
-
getMcpUrl: () => mcpUrl,
|
|
1937
|
-
// Injected rather than imported: fleet.mjs imports live.mjs, so live
|
|
1938
|
-
// cannot import back. The live worker is the DEFAULT one, and until
|
|
1939
|
-
// this was passed down the whole run-diffstat pipeline was reachable
|
|
1940
|
-
// only under FLOWVIANT_POLL=1 — the app's live-changes panel had no
|
|
1941
|
-
// data source at all for the path everybody actually runs.
|
|
1942
|
-
sampleDiffstat,
|
|
1943
|
-
isAlive: () => state.alive,
|
|
1944
|
-
onChild: (ch) => {
|
|
1945
|
-
state.child = ch;
|
|
1946
|
-
},
|
|
1947
|
-
// Which task this lane is holding, so per-process memory can be
|
|
1948
|
-
// attributed to a task rather than to an anonymous pid. "The box is
|
|
1949
|
-
// full" is not actionable; "this task is holding 9GB" is.
|
|
1950
|
-
onIntent: (id) => {
|
|
1951
|
-
state.intentId = id;
|
|
1952
|
-
},
|
|
1953
|
-
// Hold the preview's stop fn so teardown/removal can kill the detached
|
|
1954
|
-
// dev-server + tunnel (they survive our exit otherwise).
|
|
1955
|
-
onPreview: (stop) => {
|
|
1956
|
-
state.stopPreview = stop;
|
|
1957
|
-
},
|
|
1958
|
-
// A turn that couldn't reach the MCP server: forget the cached token so
|
|
1959
|
-
// the next reconcile poll re-mints a fresh one (self-heals a token that
|
|
1960
|
-
// was rotated/expired out from under a running session).
|
|
1961
|
-
onTokenSuspect: (id) => {
|
|
1962
|
-
tokenByAgent.delete(id);
|
|
1963
|
-
mintedAt.delete(id);
|
|
1964
|
-
},
|
|
1965
|
-
});
|
|
1966
|
-
workers.set(a.agentId, { state, promise, wt, label });
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
1209
|
|
|
1970
1210
|
// Living-wiki work (runs under its own minted wiki token — no agent
|
|
1971
1211
|
// needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
|